Skip to content

SQL: getting data out of a database

Chapter 3 20 code blocks 2784 words

The code in this chapter uses these filesrun it on your own machine

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.

python9 lines
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())
Output
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?

python6 lines
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 worksOn 40M rows
pandasLoads the whole table INTO MEMORY, then computesRuns out of memory or the computer freezes
SQLComputes INSIDE THE DATABASE, only the result comes backA 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.

python19 lines
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")
Output
Tables: ['rentals', 'districts']
rentals   : 1200 rows
districts : 7 rows

Now we write one function, and the whole chapter uses it.

python5 lines
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"))
tumanxonalarmaydon_m2narx_usd
Chilonzor240.6760
Yakkasaroy255.5550
Mirzo Ulug'bek51141 415
Mirzo Ulug'bek372.1985
Chilonzor246.4550

SELECT, WHERE, ORDER BY

SQL reads almost like plain English. Here is the translation:

python7 lines
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"},
]))
SQLIn plain English
SELECT col1, col2GIVE ME these columns
FROM tableFrom this table
WHERE conditionOnly the rows that match this condition
ORDER BY col DESCSorted by this column, largest first
LIMIT 10Only the first 10

Question: "Two-room furnished flats in Yunusobod: which is the most expensive?"

python9 lines
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
"""))
tumanxonalarmaydon_m2metro_kmnarx_usd
Yunusobod267.13.86970
Yunusobod2521.02935
Yunusobod252.82.22910
Yunusobod256.60.64890
Yunusobod253.41.82885
Yunusobod246.10.39865

Commonly used conditions:

python8 lines
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
"""))
totalmid_priceouter_districtno_year
1 2004402248

= NULL never works

python2 lines
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.

python3 lines
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")
Output
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".

python12 lines
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
"""))
tumanlistingsavg_pricecheapestpriciestfast_rent_pct
Yunusobod1918123201 38047.6
Yakkasaroy1047972251 29049
Mirzo Ulug'bek1617923151 47052.8
Chilonzor2117833351 38051.2
Shayxontohur1517682351 51550.3
Olmazor1587573051 47051.9
Sergeli1387482501 29052.2
Bektemir867182351 39050

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):

python7 lines
show(query("""
    SELECT xonalar, COUNT(*) AS n, ROUND(AVG(narx_usd)) AS average
    FROM rentals
    GROUP BY xonalar
    HAVING COUNT(*) > 100
    ORDER BY xonalar
"""))
xonalarnaverage
1222497
2412680
3345868
41741 046

WHERE vs HAVING: a classic interview question

  • WHERE runs BEFORE grouping and filters rows
  • HAVING runs 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.

python12 lines
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
"""))
tumanmetro_linepopulation_klistingsavg_price
YunusobodYunusobod265191812
YakkasaroyO'zbekiston115104797
Mirzo Ulug'bekChilonzor215161792
ChilonzorChilonzor245211783
ShayxontohurO'zbekiston180151768
OlmazorChilonzor290158757
SergeliSergeli195138748

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.

python12 lines
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
"""))
Output
districts in rentals  : 8
rows in districts     : 7
after the INNER JOIN  : 7

The district that went missing:
tumanlistings
Bektemir86

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.

python6 lines
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 typeWhat it keeps
INNER JOINOnly rows that match in both
LEFT JOINEVERY row on the LEFT + the matches (NULL if none)
RIGHT JOINEVERY row on the RIGHT (not in SQLite: swap the tables)
FULL OUTER JOINEvery row from both
python10 lines
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
"""))
Output
With LEFT JOIN every district is kept:
tumanmetro_linelistings
ChilonzorChilonzor211
YunusobodYunusobod191
Mirzo Ulug'bekChilonzor161
OlmazorChilonzor158
ShayxontohurO'zbekiston151
SergeliSergeli138
YakkasaroyO'zbekiston104
Bektemirno metro86

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?"

python13 lines
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
"""))
tumanxonalarnarx_usddistrict_avgdiffrank_in_district
Sergeli51 2907485421
Sergeli41 2207484722
Sergeli51 2107484623
Sergeli31 1657484174
Sergeli41 1607484125
Sergeli41 1357483876
Sergeli41 1307483827
Sergeli41 1257483778
Sergeli41 0907483429
Sergeli41 06574831710

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

python12 lines
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"},
]))
TaskWhich oneWhy
Pull 10 thousand rows out of 40 millionSQLIt won't all fit in memory
Grouping, aggregating, JOINSQLThe database does it fast with indexes
Drawing a chartpandasSQL doesn't draw charts
Training a modelpandassklearn expects a DataFrame/array
Complex cleaning, applypandasYou can use Python functions

The practical pattern: shrink it with SQL, work on it with pandas.

python12 lines
# 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))
Output
From the database: 1199 rows, 7 columns
xonalarmaydon_m2metro_kmmebeltumanpopulation_kijaraga_berildi_30k
240.61.431Chilonzor2450
255.54.181Yakkasaroy1150
51143.841Mirzo Ulug'bek2150
372.12.991Mirzo Ulug'bek2150
246.44.580Chilonzor2451

This DataFrame is now ready for step 2 of the protocol from Chapter 1.

What we learned today

python9 lines
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"},
]))
CommandWhat it does
SELECT / FROM / WHEREChoose columns and rows
IS NULLCheck for missing values (= NULL doesn't work)
GROUP BY + AVG/COUNTCompute per group
HAVINGFilter groups (WHERE filters rows)
INNER JOINConnects the matches, and LOSES rows
LEFT JOINKeeps everything on the left
OVER (PARTITION BY)Keeps the row and adds a group calculation

Always write these two lines after a JOIN

python2 lines
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.

Write on Telegram Contents