SQL: getting data out of a database
The scene: there is no CSV
You learned the protocol in Chapter 1. Its first step is pd.read_csv(...).
Then you start a job and the director says: "The data is in the database. Here are the connection details."
There's no CSV. Nobody hands you a file. The data is in a database, it's 40 million rows, and you can't load all of it.
This chapter solves that. It is step 1 of the protocol, but under real conditions.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.
Why pandas isn't enough
Let's ask a question: "what is the average price in each district?"
There are two ways.
import pandas as pd import sqlite3 df = pd.read_csv(DATA / "uy_ijara.csv") # Way 1: pandas, which first loads EVERYTHING into memory result_pandas = df.groupby("tuman").narx_usd.mean().round(1).sort_values(ascending=False) print("pandas:") print(result_pandas.to_string())
pandas: tuman Yunusobod 811.8 Yakkasaroy 797.4 Mirzo Ulug'bek 792.4 Chilonzor 783.5 Shayxontohur 767.8 Olmazor 756.8 Sergeli 747.6 Bektemir 718.4
That worked, because we have 1200 rows. But what about 40 million?
show(pd.DataFrame([ {"": "pandas", "How it works": "Loads the whole table INTO MEMORY, then computes", "On 40M rows": "Runs out of memory or the computer freezes"}, {"": "SQL", "How it works": "Computes INSIDE THE DATABASE, only the result comes back", "On 40M rows": "A few seconds, 8 rows come back"}, ]))
| How it works | On 40M rows | |
|---|---|---|
| pandas | Loads the whole table INTO MEMORY, then computes | Runs out of memory or the computer freezes |
| SQL | Computes INSIDE THE DATABASE, only the result comes back | A few seconds, 8 rows come back |
The rule is simple: when the data is big, do the computation where the data lives. You only bring the result into memory.
Working with a database: three lines of code
sqlite3 is built into Python; there's nothing to install. Let's make a small database ourselves.
db = sqlite3.connect(":memory:") # an in-memory database (no file needed) df.to_sql("rentals", db, index=False, if_exists="replace") # a second table, needed for JOIN districts = pd.DataFrame([ {"tuman": "Yunusobod", "metro_line": "Yunusobod", "population_k": 265}, {"tuman": "Chilonzor", "metro_line": "Chilonzor", "population_k": 245}, {"tuman": "Mirzo Ulug'bek", "metro_line": "Chilonzor", "population_k": 215}, {"tuman": "Shayxontohur", "metro_line": "O'zbekiston", "population_k": 180}, {"tuman": "Yakkasaroy", "metro_line": "O'zbekiston", "population_k": 115}, {"tuman": "Olmazor", "metro_line": "Chilonzor", "population_k": 290}, {"tuman": "Sergeli", "metro_line": "Sergeli", "population_k": 195}, # NOTE: Bektemir is MISSING, we need that for the LEFT JOIN lesson ]) districts.to_sql("districts", db, index=False, if_exists="replace") print("Tables:", pd.read_sql("SELECT name FROM sqlite_master WHERE type='table'", db).name.tolist()) print(f"rentals : {pd.read_sql('SELECT COUNT(*) n FROM rentals', db).n[0]} rows") print(f"districts : {pd.read_sql('SELECT COUNT(*) n FROM districts', db).n[0]} rows")
Tables: ['rentals', 'districts'] rentals : 1200 rows districts : 7 rows
Now we write one function, and the whole chapter uses it.
def query(sql): """Runs the SQL and returns the result as a DataFrame.""" return pd.read_sql(sql, db) show(query("SELECT tuman, xonalar, maydon_m2, narx_usd FROM rentals LIMIT 5"))
| tuman | xonalar | maydon_m2 | narx_usd |
|---|---|---|---|
| Chilonzor | 2 | 40.6 | 760 |
| Yakkasaroy | 2 | 55.5 | 550 |
| Mirzo Ulug'bek | 5 | 114 | 1 415 |
| Mirzo Ulug'bek | 3 | 72.1 | 985 |
| Chilonzor | 2 | 46.4 | 550 |
SELECT, WHERE, ORDER BY
SQL reads almost like plain English. Here is the translation:
show(pd.DataFrame([ {"SQL": "SELECT col1, col2", "In plain English": "GIVE ME these columns"}, {"SQL": "FROM table", "In plain English": "From this table"}, {"SQL": "WHERE condition", "In plain English": "Only the rows that match this condition"}, {"SQL": "ORDER BY col DESC", "In plain English": "Sorted by this column, largest first"}, {"SQL": "LIMIT 10", "In plain English": "Only the first 10"}, ]))
| SQL | In plain English |
|---|---|
| SELECT col1, col2 | GIVE ME these columns |
| FROM table | From this table |
| WHERE condition | Only the rows that match this condition |
| ORDER BY col DESC | Sorted by this column, largest first |
| LIMIT 10 | Only the first 10 |
Question: "Two-room furnished flats in Yunusobod: which is the most expensive?"
show(query(""" SELECT tuman, xonalar, maydon_m2, metro_km, narx_usd FROM rentals WHERE tuman = 'Yunusobod' AND xonalar = 2 AND mebel = 1 ORDER BY narx_usd DESC LIMIT 6 """))
| tuman | xonalar | maydon_m2 | metro_km | narx_usd |
|---|---|---|---|---|
| Yunusobod | 2 | 67.1 | 3.86 | 970 |
| Yunusobod | 2 | 52 | 1.02 | 935 |
| Yunusobod | 2 | 52.8 | 2.22 | 910 |
| Yunusobod | 2 | 56.6 | 0.64 | 890 |
| Yunusobod | 2 | 53.4 | 1.82 | 885 |
| Yunusobod | 2 | 46.1 | 0.39 | 865 |
Commonly used conditions:
show(query(""" SELECT COUNT(*) AS total, SUM(CASE WHEN narx_usd BETWEEN 400 AND 700 THEN 1 ELSE 0 END) AS mid_price, SUM(CASE WHEN tuman IN ('Sergeli', 'Bektemir') THEN 1 ELSE 0 END) AS outer_district, SUM(CASE WHEN qurilgan_yil IS NULL THEN 1 ELSE 0 END) AS no_year FROM rentals """))
| total | mid_price | outer_district | no_year |
|---|---|---|---|
| 1 200 | 440 | 224 | 8 |
= NULL never works
WHERE qurilgan_yil = NULL -- ALWAYS returns nothing WHERE qurilgan_yil IS NULL -- CORRECT
NULL means "no value", and it is not equal to anything, not even to itself. This is the most common mistake in SQL.
print("Proof:") print(" = NULL result:", query("SELECT COUNT(*) n FROM rentals WHERE qurilgan_yil = NULL").n[0], "rows") print(" IS NULL result:", query("SELECT COUNT(*) n FROM rentals WHERE qurilgan_yil IS NULL").n[0], "rows")
Proof: = NULL result: 0 rows IS NULL result: 8 rows
GROUP BY: computing per group
This is the main weapon of analytics. It answers questions of the form "for each X, how much Y".
show(query(""" SELECT tuman, COUNT(*) AS listings, ROUND(AVG(narx_usd)) AS avg_price, MIN(narx_usd) AS cheapest, MAX(narx_usd) AS priciest, ROUND(AVG(ijaraga_berildi_30k) * 100, 1) AS fast_rent_pct FROM rentals GROUP BY tuman ORDER BY avg_price DESC """))
| tuman | listings | avg_price | cheapest | priciest | fast_rent_pct |
|---|---|---|---|---|---|
| Yunusobod | 191 | 812 | 320 | 1 380 | 47.6 |
| Yakkasaroy | 104 | 797 | 225 | 1 290 | 49 |
| Mirzo Ulug'bek | 161 | 792 | 315 | 1 470 | 52.8 |
| Chilonzor | 211 | 783 | 335 | 1 380 | 51.2 |
| Shayxontohur | 151 | 768 | 235 | 1 515 | 50.3 |
| Olmazor | 158 | 757 | 305 | 1 470 | 51.9 |
| Sergeli | 138 | 748 | 250 | 1 290 | 52.2 |
| Bektemir | 86 | 718 | 235 | 1 390 | 50 |
One query, and you have a picture of the whole market. In Chapter 4 this table becomes a chart.
HAVING filters groups (WHERE filters rows):
show(query(""" SELECT xonalar, COUNT(*) AS n, ROUND(AVG(narx_usd)) AS average FROM rentals GROUP BY xonalar HAVING COUNT(*) > 100 ORDER BY xonalar """))
| xonalar | n | average |
|---|---|---|
| 1 | 222 | 497 |
| 2 | 412 | 680 |
| 3 | 345 | 868 |
| 4 | 174 | 1 046 |
WHERE vs HAVING: a classic interview question
WHEREruns BEFORE grouping and filters rowsHAVINGruns AFTER grouping and filters groups
Write WHERE COUNT(*) > 100 and you get an error: grouping hasn't happened yet, so COUNT doesn't exist.
JOIN: connecting tables
In a real database the data isn't in one table. Flats live in one table, information about districts in another.
show(query(""" SELECT r.tuman, d.metro_line, d.population_k, COUNT(*) AS listings, ROUND(AVG(r.narx_usd)) AS avg_price FROM rentals r INNER JOIN districts d ON r.tuman = d.tuman GROUP BY r.tuman, d.metro_line, d.population_k ORDER BY avg_price DESC """))
| tuman | metro_line | population_k | listings | avg_price |
|---|---|---|---|---|
| Yunusobod | Yunusobod | 265 | 191 | 812 |
| Yakkasaroy | O'zbekiston | 115 | 104 | 797 |
| Mirzo Ulug'bek | Chilonzor | 215 | 161 | 792 |
| Chilonzor | Chilonzor | 245 | 211 | 783 |
| Shayxontohur | O'zbekiston | 180 | 151 | 768 |
| Olmazor | Chilonzor | 290 | 158 | 757 |
| Sergeli | Sergeli | 195 | 138 | 748 |
Notice: the result has 7 districts. But the data had 8.
Bektemir disappeared, because it isn't in the districts table, and INNER JOIN keeps only rows that exist in both.
print("districts in rentals :", query("SELECT COUNT(DISTINCT tuman) n FROM rentals").n[0]) print("rows in districts :", query("SELECT COUNT(*) n FROM districts").n[0]) print("after the INNER JOIN :", len(query("SELECT DISTINCT r.tuman FROM rentals r INNER JOIN districts d ON r.tuman=d.tuman"))) print() print("The district that went missing:") show(query(""" SELECT r.tuman, COUNT(*) AS listings FROM rentals r LEFT JOIN districts d ON r.tuman = d.tuman WHERE d.tuman IS NULL GROUP BY r.tuman """))
districts in rentals : 8 rows in districts : 7 after the INNER JOIN : 7 The district that went missing:
| tuman | listings |
|---|---|
| Bektemir | 86 |
INNER JOIN loses data SILENTLY
This is the most expensive SQL mistake in ML projects. You write INNER JOIN, the query runs, no error appears, and 93 rows vanish from your dataset.
Then you train a model, and it never sees the Bektemir district.
The rule: always check the row count after a JOIN. If it dropped, switch to LEFT JOIN and find out why.
show(pd.DataFrame([ {"JOIN type": "INNER JOIN", "What it keeps": "Only rows that match in both"}, {"JOIN type": "LEFT JOIN", "What it keeps": "EVERY row on the LEFT + the matches (NULL if none)"}, {"JOIN type": "RIGHT JOIN", "What it keeps": "EVERY row on the RIGHT (not in SQLite: swap the tables)"}, {"JOIN type": "FULL OUTER JOIN", "What it keeps": "Every row from both"}, ]))
| JOIN type | What it keeps |
|---|---|
| INNER JOIN | Only rows that match in both |
| LEFT JOIN | EVERY row on the LEFT + the matches (NULL if none) |
| RIGHT JOIN | EVERY row on the RIGHT (not in SQLite: swap the tables) |
| FULL OUTER JOIN | Every row from both |
print("With LEFT JOIN every district is kept:") show(query(""" SELECT r.tuman, COALESCE(d.metro_line, 'no metro') AS metro_line, COUNT(*) AS listings FROM rentals r LEFT JOIN districts d ON r.tuman = d.tuman GROUP BY r.tuman, d.metro_line ORDER BY listings DESC """))
With LEFT JOIN every district is kept:
| tuman | metro_line | listings |
|---|---|---|
| Chilonzor | Chilonzor | 211 |
| Yunusobod | Yunusobod | 191 |
| Mirzo Ulug'bek | Chilonzor | 161 |
| Olmazor | Chilonzor | 158 |
| Shayxontohur | O'zbekiston | 151 |
| Sergeli | Sergeli | 138 |
| Yakkasaroy | O'zbekiston | 104 |
| Bektemir | no metro | 86 |
Window functions: computing per group without collapsing it
GROUP BY collapses rows. Sometimes you need to keep every row and put a per-group number next to it.
Question: "Is each flat more or less expensive than the average in its district?"
show(query(""" SELECT tuman, xonalar, narx_usd, ROUND(AVG(narx_usd) OVER (PARTITION BY tuman)) AS district_avg, narx_usd - ROUND(AVG(narx_usd) OVER (PARTITION BY tuman)) AS diff, RANK() OVER (PARTITION BY tuman ORDER BY narx_usd DESC) AS rank_in_district FROM rentals WHERE tuman IN ('Yunusobod', 'Sergeli') ORDER BY tuman, rank_in_district LIMIT 10 """))
| tuman | xonalar | narx_usd | district_avg | diff | rank_in_district |
|---|---|---|---|---|---|
| Sergeli | 5 | 1 290 | 748 | 542 | 1 |
| Sergeli | 4 | 1 220 | 748 | 472 | 2 |
| Sergeli | 5 | 1 210 | 748 | 462 | 3 |
| Sergeli | 3 | 1 165 | 748 | 417 | 4 |
| Sergeli | 4 | 1 160 | 748 | 412 | 5 |
| Sergeli | 4 | 1 135 | 748 | 387 | 6 |
| Sergeli | 4 | 1 130 | 748 | 382 | 7 |
| Sergeli | 4 | 1 125 | 748 | 377 | 8 |
| Sergeli | 4 | 1 090 | 748 | 342 | 9 |
| Sergeli | 4 | 1 065 | 748 | 317 | 10 |
The rows didn't disappear: each one is still there, with a group calculation next to it. You can't do that with GROUP BY.
This is very useful for ML: columns like these make good features (Chapter 23).
SQL or pandas: when to use which
show(pd.DataFrame([ {"Task": "Pull 10 thousand rows out of 40 million", "Which one": "SQL", "Why": "It won't all fit in memory"}, {"Task": "Grouping, aggregating, JOIN", "Which one": "SQL", "Why": "The database does it fast with indexes"}, {"Task": "Drawing a chart", "Which one": "pandas", "Why": "SQL doesn't draw charts"}, {"Task": "Training a model", "Which one": "pandas", "Why": "sklearn expects a DataFrame/array"}, {"Task": "Complex cleaning, apply", "Which one": "pandas", "Why": "You can use Python functions"}, ]))
| Task | Which one | Why |
|---|---|---|
| Pull 10 thousand rows out of 40 million | SQL | It won't all fit in memory |
| Grouping, aggregating, JOIN | SQL | The database does it fast with indexes |
| Drawing a chart | pandas | SQL doesn't draw charts |
| Training a model | pandas | sklearn expects a DataFrame/array |
| Complex cleaning, apply | pandas | You can use Python functions |
The practical pattern: shrink it with SQL, work on it with pandas.
# Pull ONLY the part we need from the database: this is the real-project pattern for_model = query(""" SELECT r.xonalar, r.maydon_m2, r.metro_km, r.mebel, r.tuman, COALESCE(d.population_k, 0) AS population_k, r.ijaraga_berildi_30k FROM rentals r LEFT JOIN districts d ON r.tuman = d.tuman WHERE r.narx_usd BETWEEN 200 AND 1500 """) print(f"From the database: {for_model.shape[0]} rows, {for_model.shape[1]} columns") print() show(for_model.head(5))
From the database: 1199 rows, 7 columns
| xonalar | maydon_m2 | metro_km | mebel | tuman | population_k | ijaraga_berildi_30k |
|---|---|---|---|---|---|---|
| 2 | 40.6 | 1.43 | 1 | Chilonzor | 245 | 0 |
| 2 | 55.5 | 4.18 | 1 | Yakkasaroy | 115 | 0 |
| 5 | 114 | 3.84 | 1 | Mirzo Ulug'bek | 215 | 0 |
| 3 | 72.1 | 2.99 | 1 | Mirzo Ulug'bek | 215 | 0 |
| 2 | 46.4 | 4.58 | 0 | Chilonzor | 245 | 1 |
This DataFrame is now ready for step 2 of the protocol from Chapter 1.
What we learned today
show(pd.DataFrame([ {"Command": "SELECT / FROM / WHERE", "What it does": "Choose columns and rows"}, {"Command": "IS NULL", "What it does": "Check for missing values (= NULL doesn't work)"}, {"Command": "GROUP BY + AVG/COUNT", "What it does": "Compute per group"}, {"Command": "HAVING", "What it does": "Filter groups (WHERE filters rows)"}, {"Command": "INNER JOIN", "What it does": "Connects the matches, and LOSES rows"}, {"Command": "LEFT JOIN", "What it does": "Keeps everything on the left"}, {"Command": "OVER (PARTITION BY)", "What it does": "Keeps the row and adds a group calculation"}, ]))
| Command | What it does |
|---|---|
| SELECT / FROM / WHERE | Choose columns and rows |
| IS NULL | Check for missing values (= NULL doesn't work) |
| GROUP BY + AVG/COUNT | Compute per group |
| HAVING | Filter groups (WHERE filters rows) |
| INNER JOIN | Connects the matches, and LOSES rows |
| LEFT JOIN | Keeps everything on the left |
| OVER (PARTITION BY) | Keeps the row and adds a group calculation |
Always write these two lines after a JOIN
print(len(before), "->", len(after)) assert len(after) == len(before), "The JOIN lost rows!"
These two lines protect you from the quietest kind of data loss.
Next chapter: the data has arrived, but you can't read 1200 rows by eye. We'll look at it.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.