Demos and Recipes LSE ME204 · Data Engineering Principles for the Social Sciences
🖥️ Week 03 Day 02 Lecture
10:00 – 11:00
I’ll be going around the room to help you. Use this hour to make progress on your project.
What you could be doing right now:
💡 Deadline reminder: submit your final project by 5pm Friday 31 July.
11:00 – 11:15

When we come back:
hourly_readings, side by side in SQL and pandas11:15 – 11:35
Structure and strictness increase as you move from a list to a SQL table.
A plain list
Any type in any position. Nothing stops you putting a string where a number should be.
A dict of lists
Columns have names, but nothing enforces that every column has the same length or the same type throughout.
A DataFrame
One type per column, enforced the moment the DataFrame is built.
A SQL table
Types are declared before any data goes in, and the table can also declare which columns identify a row.
DataFrames: you build them yourself
In Weeks 01 and 02 you called an API, got JSON back, and built a DataFrame from the response. You controlled what went in.
Once data exists in either form, the same questions come up: filter rows, group and aggregate, sort, join two tables. SQL and pandas are two ways to ask them.
| pandas | SQL |
|---|---|
df.head(5) |
SELECT * FROM table LIMIT 5 |
df[df["col"] > val] |
SELECT * FROM table WHERE col > val |
df.groupby("col").size() |
SELECT col, COUNT(*) FROM table GROUP BY col |
df.sort_values("col", ascending=False) |
SELECT * FROM table ORDER BY col DESC |
pd.merge(df1, df2, on="key") |
SELECT ... FROM t1 JOIN t2 ON t1.key = t2.key |
Section 2 builds each of these pairs on real data.
Python’s built-in sqlite3 module. No installation needed.
sqlite3 is part of Python’s standard library. connect opens an existing database file or creates a new one if the file does not exist yet. That file does not exist yet, so this call creates it.
Everything you do with this database goes through conn. Queries, inserts, schema changes: they all start from the connection object.
💡 Let me show you in the notebook. Open the lecture solutions notebook and run the sqlite3.connect cell.
A DataFrame gets its types from the data you load. A SQL table declares them first, then accepts data.
DROP TABLE IF EXISTS removes any previous version. CREATE TABLE declares the contract: column names, types, and constraints. No data has been inserted yet.
Note the filename: my_weather.db. You are building your own database here just to avoid overwriting the one we will be using in the afternoon class.
⚠️ Loading data: append, not replace
if_exists='append' inserts rows into the table you just created, keeping the declared schema.
if_exists='replace' would drop that table and let pandas guess a schema from scratch. Always use append when you have created the schema yourself.
PRAGMA table_info is SQLite’s equivalent of df.info().
Which tables exist?
| name | |
|---|---|
| 0 | geocoding_matches |
| 1 | hourly_readings |
| 2 | weather_conditions |
What columns does a table have?
| cid | name | type | notnull | dflt_value | pk | |
|---|---|---|---|---|---|---|
| 0 | 0 | dt | BIGINT | 0 | None | 0 |
| 1 | 1 | temp | DECIMAL(5,2) | 0 | None | 0 |
| 2 | 2 | feels_like | DECIMAL(5,2) | 0 | None | 0 |
| 3 | 3 | pressure | INTEGER | 0 | None | 0 |
| 4 | 4 | humidity | INTEGER | 0 | None | 0 |
| 5 | 5 | city | VARCHAR(20) | 0 | None | 0 |
💡 Let me show you in the notebook. Run the sqlite_master and PRAGMA cells now.
11:35 – 12:05
Every SQL query starts here: which columns, from which table.
SQL
SELECT * means every column. FROM names the table. LIMIT 5 stops after five rows, so you can see what the data looks like without printing thousands of lines.
Keep only the rows that match a condition.
SQL
WHERE filters rows before they reach the result. AND combines two conditions: the city must be 'london' and the temperature must be above 15.
pandas: .query()
pandas: .loc[]
.query() reads like the SQL WHERE. .loc[] does the same thing with boolean indexing. Notice == for equality in Python, = in SQL.
💡 Run the WHERE cell in the notebook.
For every city, what is the average temperature among rows above 15°C?
SQL
GROUP BY city collapses rows into one row per city. AVG(temp) computes the mean inside each group. AS avg_temp gives the result column a name. ORDER BY ... DESC sorts from highest to lowest.
💡 Run the GROUP BY cell in the notebook. The SQL and pandas outputs should match.
pd.read_sql returns a DataFrameEvery query result comes back as a DataFrame you already know how to work with.
SQL gets you the rows you need from the database. The result comes back as a DataFrame, and from there you can use every pandas method you already know.
So far every question used one table. What happens when the answer needs columns from two tables? That is what a join does ⏭️
You write the clauses in one order. The database runs them in another.
Written order
Execution order
FROM: pick the tableWHERE: filter rowsGROUP BY: collapse into groupsSELECT: pick columns and computeORDER BY: sort the resultThis is why you cannot use a column alias from SELECT inside WHERE: the database has not run SELECT yet when it filters.
💡 SQLite and types. SQLite is more forgiving than most databases. It uses “type affinity”, which means it tries to convert values to the declared type but does not always reject a mismatch. Other databases (PostgreSQL, MySQL) are stricter.
12:05 – 12:35
hourly_readings has weather data. geocoding_matches has country and state. To answer “which UK cities were warmest?” you need columns from both.
geocoding_matches (sample)
| query_name | name | country |
|---|---|---|
| London | London | GB |
| London | City of London | GB |
| London | Chelsea | GB |
| London | London | CA |
| London | London | US |
| Edinburgh | City of Edinburgh | GB |
| Edinburgh | Old Town | GB |
| Edinburgh | Edinburgh | US |
| Cardiff | Cardiff | GB |
| Belfast | Belfast | GB |
Each city has its own colour. Saturated rows are GB, pale rows are the same name in another country.
Four searches, twenty rows, and seven of them are in GB. London alone returns three.
hourly_readings (sample)
| city | temp | humidity | dt |
|---|---|---|---|
| london | 16.03 | 78 | 1754006400 |
| edinburgh | 12.89 | 82 | 1754006400 |
| cardiff | 15.13 | 83 | 1754006400 |
| belfast | 13.94 | 79 | 1754006400 |
Notice: city is lowercase (london), but query_name is capitalised (London). A join has to account for that.
A join combines rows from two tables where a condition matches.
geocoding_matches
| query_name | country |
|---|---|
| London | GB |
| London | CA |
| London | US |
| Edinburgh | GB |
| Edinburgh | US |
hourly_readings
| city | temp |
|---|---|
| london | 16.03 |
| edinburgh | 12.89 |
→
result
| query_name | country | temp |
|---|---|---|
| London | GB | 16.03 |
| Edinburgh | GB | 12.89 |
The result has columns from both tables. Rows that share a colour matched on the city name.
Two questions decide what the result looks like:
Which rows match? The ON clause sets the condition.
What happens to rows that do not match? That is the difference between INNER JOIN and LEFT JOIN.
The obvious query looks right and triples the data.
LOWER() handles London against london, and country = 'GB' drops the foreign matches. Nothing here is a syntax error.
| query_name | rows_returned |
|---|---|
| London | 180 |
| Edinburgh | 120 |
| Cardiff | 60 |
| Belfast | 60 |
60 readings per city went in. London came back 180 times, Edinburgh 120.
⚠️ London has three GB matches, so each of its 60 readings is paired three times. An average over this result gives the same number as the correct query, because every value repeats equally. COUNT(*) is what catches it.
Join on a column that identifies one row, and each reading matches once.
cities has one row per city, and hourly_readings.city points at it. Each of the 240 readings matches exactly one city row, so the average is over 60 readings per city.
On the previous slide, joining on query_name gave London 180 rows. Here it gives 60.
Result (sample)
| name | state | avg_temp |
|---|---|---|
| London | England | 17.09 |
| Belfast | Northern Ireland | 16.56 |
| Cardiff | Wales | 16.27 |
| City of Edinburgh | Scotland | 15.66 |
Four rows, one per city, each an average over that city’s 60 readings.
cities stores City of Edinburgh, the name the Geocoding API returned, not the name you searched for.
💡 Run the INNER JOIN cell in the notebook.
Keep every row from the left table. Fill in NULLs where the right table has no match.
country = 'GB' goes in ON, not WHERE. A WHERE filter would drop every row outside GB after the join, which defeats the point of keeping all 20.
In ON it decides which rows find a match, not which rows survive.
Result (sample)
| query_name | name | country | uk_region |
|---|---|---|---|
| Belfast | Belfast | GB | Northern Ireland |
| Belfast | Belfast | NZ | NULL |
| London | London | GB | England |
| London | Chelsea | GB | NULL |
| London | City of London | GB | NULL |
| London | London | CA | NULL |
All 20 rows come back. 4 matched, 16 are NULL.
Look at Chelsea and City of London. Both are in GB, and both are NULL, because neither is the row cities stores for London. Being in the right country is not the same as being the right row.
NULL in SQL is NaN in pandas. Check for it with .isna().
INNER and LEFT are the two you will use most. Two others exist:
For a full reference: W3Schools SQL Joins
pd.mergepd.merge is the pandas equivalent of a SQL JOIN.
INNER (readings to cities, on the key)
how=“inner” keeps only matched rows, the same as JOIN in SQL.
on="city" is the ON clause. Both frames name the column the same way, so one argument covers it.
LEFT (every geocoded match, region where known)
how=“left” keeps every row from the left DataFrame, with NaN where there is no match.
SQL puts the GB condition in ON. pandas has no ON to put it in, so filter the right frame before merging.
.pipe(): a readable pipeline in pandasIf you liked how a SQL query reads as a sequence of named steps, .pipe() gives you the same thing in pandas.
Define each step as a function
Chain them with .pipe()
This answers the Section 2 question again, and the name of each function stands in for the explanation you would otherwise write as a comment.
Compare with the version that reassigns to a new variable at every step:
Both produce the same result. The .pipe() version reads as one pipeline.
.pipe() for loading and reshaping data.pipe() is especially useful when one of the steps is not a DataFrame method. Loading a JSON file and normalising it cannot be chained with dot syntax, but you can wrap it in a function:
record_path="data" tells json_normalize which nested list holds the records you want, so it returns one row per hourly reading.
12:35 – 13:00
This afternoon
You will practise querying and joining data in SQLite, and your class teacher can also help you with your final project.
Where you want to be by tonight
⚠️ Submit by 5pm Friday 31 July.
LSE Summer School 2026 | ME204 Week 03 Day 02
LSE ME204 (2026)