DS 208 · Week 9

Working with Data Sources

Beyond the CSV: other file formats, rows pulled from a SQL database, and live data fetched from a web API.

Programming for Data Science · University of the Philippines Cebu

Session Map

Three places data lives

Words for Today

Ten words you will hear this week

file format

The agreed layout of the data inside a file; it decides which reader you use.

e.g. CSV, JSON, Excel, Parquet → read_csv, read_json, …

JSON

Text written like Python lists and dictionaries; the usual format of web replies.

e.g. [{"city": "Cebu", "pop": 964000}]

encoding

The codebook that turns a file's bytes into letters. The wrong one garbles ñ.

e.g. encoding="utf-8"

file path

A file's address: the folders to walk through, then its name.

e.g. data/raw/flood.csv

database / table

A store of many named tables you ask questions of, instead of opening a file.

e.g. the cities table inside cities.db

SQL query

One question to a database, written in SQL: which columns, from which table, which rows.

e.g. SELECT city FROM cities WHERE pop > 900000

connection

An open line between your program and a database; queries travel through it.

e.g. conn = sqlite3.connect("cities.db")

API / endpoint

A web address built for programs; each endpoint is one address that serves one kind of data.

e.g. /api/flood/projects

request / response

The message you send to a web server, and the reply it sends back.

e.g. r = requests.get(url) — r is the response

status code

The three-digit number on every response saying whether it worked.

e.g. 200 OK, 404 not found, 429 too many requests

Where We Left Off

You've only met one door in

Everything so far started with read_csv. But data also sits in databases and behind web APIs. Same DataFrame at the end — different way in.

Quick reminders

DataFrame
pandas' table: rows and named columns (week 6)
dtype
a column's data type, e.g. int64, float64, object (text)
dictionary
Python's name → value lookup, {"city": "Cebu"} (week 3)
merge
combine two tables by matching a key column (week 7)

The constant

Once loaded, it's a DataFrame — all your pandas skills apply.

The variable

How you get there: a file reader, a SQL query, or an HTTP request.

Part A

File Formats

One reader per format.

You already load CSV files with read_csv. This part adds the other common file formats — the agreed layout of the bytes inside a file — and the two things that trip people up: text encoding and file paths.

Why Not Always CSV?

CSV is simple, but forgetful

A CSV stores only text — types are guessed on load, and nesting is impossible. JSON keeps structure, Excel keeps sheets, and each has its place.

New words

CSV
"comma-separated values": plain text, one row per line, commas between cells
JSON
text written like Python lists and dictionaries: [{"city": "Cebu", "pop": 964000}]
Excel
a spreadsheet file (.xlsx) that can hold several sheets
nesting
a value that itself holds values, like a dictionary inside a dictionary

CSV

Flat text. Universal, but loses types.

JSON

Nested, self-describing. The web's format.

Excel

Multiple sheets. Common from offices.

Meet JSON

JSON is text that looks like Python

JSON (JavaScript Object Notation) writes data as plain text using two shapes you know from week 3: [ ] for a list and { } for a dictionary of key: value pairs. Almost every web API replies in JSON.

A CSV is a grid on paper. JSON is a set of labelled index cards: every value carries its own label, and a card can hold smaller cards inside it.

import json import pandas as pd txt = '[{"city": "Cebu", "pop": 964000}, {"city": "Davao", "pop": 1777000}]' data = json.loads(txt) print(type(data), type(data[0])) print(pd.DataFrame(data)) <class 'list'> <class 'dict'> city pop 0 Cebu 964000 1 Davao 1777000
  • txt = '[{…}, {…}]'JSON is just text (a string): a list holding two records
  • json.loads(txt)"load string": turn the JSON text into real Python objects
  • <class 'list'> <class 'dict'>the outside is a list; each item in it is a dictionary
  • pd.DataFrame(data)one row per dictionary, one column per key
Same Idea, Different Suffix

pandas has a reader for each

The pattern is identical: read_ plus the format. Excel files can hold many sheets, so you name the one you want.

The text in quotes is a file path: the file's address on your computer. A bare name like "data.csv" means "in the same folder as this notebook".

pd.read_csv("data.csv") pd.read_json("data.json") pd.read_excel("data.xlsx", sheet_name="2024")
  • pd.read_json("data.json")read a JSON file whose records become rows
  • pd.read_excel(…, sheet_name="2024")read one sheet (tab) of a workbook, the one named 2024
  • each linegives back a DataFrame, so everything from weeks 6–8 works on it
Pick The Right One

Four formats, at a glance

FormatBest forReader
CSVsimple, shareable tablesread_csv
JSONnested data, web responsesread_json
Excelmulti-sheet office filesread_excel
Parquetbig data, fast + typedread_parquet
Measured, On 34,079 Rows

The same table, five ways

FormatSizeWriteReadKeeps dtypes?
CSV11.44 MB104 ms59 msNo — everything is text
CSV.gz2.84 MB319 ms65 msNo
JSON18.61 MB167 ms103 msPartly
Parquet3.37 MB72 ms84 msYes
SQLite12.13 MB89 ms77 msYes
Why CSV Forgets

There is nowhere in the file to record a type

A CSV is characters and commas. Everything you learned in week 6 about pd.to_numeric exists because the format has no slot for "this column is a float".

Parquet stores the schema

The schema is the list of columns and their types. It travels with the data, so the read-back needs no conversion step and cannot guess wrong.

# CSV round-trip: types are lost df.to_csv("f.csv") pd.read_csv("f.csv").budget.dtype float64 # pandas GUESSED right # this time # Parquet round-trip: types are kept df.to_parquet("f.parquet") pd.read_parquet("f.parquet").budget.dtype float64 # recorded, not guessed
  • df.to_csv("f.csv")write the table to a CSV file (every reader has a matching writer)
  • .budget.dtyperead it back and ask what type the budget column has
  • float64decimal numbers — the same answer both times, but CSV got there by guessing from the text, Parquet by reading the type it stored
Choosing

Three questions decide it

There is no best format, only a best fit. These questions pick it for you almost every time.

Like choosing between a printed letter, a PDF and a shared spreadsheet: it depends who will open it and what they will do with it.

A note about this course

The flood dataset ships as gzipped CSV, not Parquet — because the production server has no pyarrow (the extra library pandas needs to read and write Parquet). Deployment constraints beat benchmarks.

# Will a human open it in Excel? -> CSV # Is it big, and only code reads it? -> Parquet # Does it need querying without loading # the whole thing into memory? -> SQLite # Is it nested (lists inside records)? -> JSON
Paths

pathlib, so it works on someone else’s machine

A hardcoded "C:\\Users\\me\\data.csv" is the single most common reason a classmate cannot run your notebook.

A file path is a file's address: folders separated by / (Mac, Linux) or \ (Windows). A relative path (data/raw/flood.csv) starts from the current folder; an absolute one (C:\Users\…) starts from the top of one particular computer.

Relative to the file, not the cwd

The cwd (current working directory) is the folder your program happens to be running in. Notebooks are run from wherever the person happened to be. Anchor paths to a known point in the project instead.

from pathlib import Path DATA = Path("data") / "raw" / "flood.csv" df = pd.read_csv(DATA) # works on mac, linux and windows — # pathlib picks the separator DATA.exists() True DATA.stat().st_size / 1e6 11.44 # MB
  • Path("data") / "raw" / "flood.csv"build the path piece by piece; the / joins folder names with the right separator for your computer
  • DATA.exists() → Truethe file really is there
  • DATA.stat().st_size / 1e6the file's size in bytes, divided by a million: 11.44 MB
Cañete

The bug that does not raise

Half of encoding errors crash. The other half just quietly change your data.

A file stores bytes (numbers), not letters. An encoding is the codebook that turns bytes into letters; utf-8 and latin-1 are two common codebooks. Read with the wrong one and ñ turns into ñ.

Two Ways To Get It Wrong

Only one of them tells you

This matters here more than most places: Filipino names and place names are full of ñ, and ñ is exactly where the two common encodings disagree.

The silent direction is the dangerous one

A crash sends you to look. Mojibake passes every test you would think to write and lands in a published table.

# latin-1 bytes, read as utf-8 -> CRASH UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 2 # utf-8 bytes, read as latin-1 -> SILENT "Cañete".encode("utf-8").decode("latin-1") 'Cañete' # no error at all # Bañez -> Bañez # Peñaranda -> Peñaranda
  • Reading the errorread the last line first: UnicodeDecodeError is the error type — "these bytes are not valid in the codebook you named"
  • byte 0xf1 in position 2the culprit is byte 0xf1 (ñ in latin-1), third character in (counting from 0): "Cañete"
  • .encode("utf-8").decode("latin-1")turn the text into utf-8 bytes, then read them with the wrong codebook
  • 'Cañete'mojibake: garbled letters from the wrong encoding, with no error at all
Fixing It

Say the encoding; do not let pandas guess

Be explicit on read. If you do not know what the file is, look at the bytes rather than trying encodings until one stops complaining.

utf-8-sig for files from Excel

Excel writes a byte-order mark. Plain utf-8 leaves it in your first column name as an invisible character that breaks every lookup.

pd.read_csv(path, encoding="utf-8") pd.read_csv(path, encoding="latin-1") pd.read_csv(path, encoding="utf-8-sig") # Excel # when you genuinely do not know: with open(path, "rb") as f: head = f.read(4) print(head) b'\xef\xbb\xbf...' # BOM -> utf-8-sig # and the symptom to recognise: # ñ é ó means utf-8 read as latin-1
  • encoding="utf-8-sig"utf-8 that also skips the invisible byte-order mark (BOM) Excel puts at the start
  • open(path, "rb")open the file in "read bytes" mode, without decoding anything
  • f.read(4)look at the first four raw bytes; b'\xef\xbb\xbf' is the BOM
Read Only What You Need

usecols: 11× less memory

Most analyses touch three or four columns. Loading fifteen and ignoring eleven costs memory and time for nothing.

Measured on this dataset

Two columns: 24 ms and 2.6 MB. All fifteen: 63 ms and 28.5 MB (memory measured with memory_usage(deep=True)). The saving grows with the file.

pd.read_csv(path) 63 ms, 28.5 MB, 15 columns pd.read_csv(path, usecols=["region", "budget"]) 24 ms, 2.6 MB, 2 columns # also worth setting on read: pd.read_csv(path, dtype={"year": "category"}, parse_dates=["start_date"])
  • usecols=["region", "budget"]load only these two columns and skip the other thirteen
  • dtype={"year": "category"}tell pandas a column's type up front instead of letting it guess
  • parse_dates=["start_date"]read that column as real dates, not text
When The File Will Not Fit

chunksize gives you an iterator

Past a certain size you cannot hold the table in memory at all. Process it in pieces and keep only the summary.

A chunk is one piece of the file, here 5,000 rows. An iterator hands you the pieces one at a time, in a loop.

7 chunks, about 4.2 MB at a time

Against 28.5 MB for the whole frame. At 34k rows this is a demonstration; at 34M rows it is the only way it runs.

total = 0 n = 0 for chunk in pd.read_csv(path, chunksize=5000): total += chunk.budget.sum() n += len(chunk) n, total / 1e12 (34079, 1.60008878239283) # 7 chunks, ~4.2 MB in memory each # never the whole 28.5 MB at once
  • for chunk in pd.read_csv(path, chunksize=5000)read 5,000 rows, run the loop body, forget them, read the next 5,000
  • total += chunk.budget.sum()keep a running total of the budgets (+= adds to what is already there)
  • (34079, 1.60008878239283)all 34,079 rows counted; total budget about 1.60 trillion pesos (1e12 is a trillion)
Nested JSON

json_normalize flattens what read_json will not

APIs return records with objects inside them. pd.DataFrame leaves those as dicts in a cell; json_normalize spreads them into columns.

Dotted names show the nesting

loc.region came from {"loc": {"region": ...}}. You can rename afterwards, but the default is honest about where it came from.

nested = [ {"id": 1, "loc": {"region": "III", "prov": "Bulacan"}}, {"id": 2, "loc": {"region": "V", "prov": "Albay"}}, ] pd.json_normalize(nested) id loc.region loc.prov 0 1 III Bulacan 1 2 V Albay
  • nested = [ {…}, {…} ]a list of two records; each has a loc dictionary inside it
  • pd.json_normalize(nested)flatten: each inner key becomes its own column
  • loc.regionthe dot in the column name says "region, from inside loc"
Quick Check

Tap to reveal

You need to store a cleaned 34,079-row table that only your code will read, and you care about size and load time. Which format?

A · CSV — it is universal
B · Parquet — 3.4× smaller than CSV, fastest to write, and it keeps the dtypes
C · JSON — it preserves structure
D · Excel — anyone can open it
B. Measured on this dataset: Parquet 3.37 MB / 72 ms write against CSV 11.44 MB / 104 ms, and it records the schema so no to_numeric step is needed on read. JSON was the worst on every axis (18.61 MB, slowest read). Choose CSV only when a human needs to open it — which was not the requirement here.
Part B

SQL

Ask a database; load the answer.

You can now read data from files. This part reads it from a database instead, by sending it a question written in SQL — and you will recognise every idea from pandas.

When Files Aren't Enough

A database scales where a CSV breaks

Millions of rows, many people writing at once, related tables — a CSV can't do this. A database can, and SQL is the language you ask it questions in.

New words

database
a program (or file) that stores many tables and answers questions about them
table
one named grid of rows and columns inside a database, like one sheet
SQL
"Structured Query Language", said "sequel" or "S-Q-L": the language for asking
query
one question written in SQL, e.g. SELECT city FROM cities

A database is a library with a librarian. Instead of carrying every book home (loading the whole CSV), you hand over a request slip (a query) and get back only the pages you asked for.

Handles size

Queries stay fast on data too big to open in memory.

Handles sharing

Many users, one consistent source of truth.

The Core Query

SELECT what, FROM where, WHERE which

You name the columns, the table, and a condition. It reads almost like English — and it's the same filter-and-select logic you know from pandas.

SQL keywords are written in CAPITALS by habit (SQL does not mind); text values go in single quotes; the ; marks the end of the query.

SELECT city, pop FROM cities WHERE region = 'Visayas' ORDER BY pop DESC;
  • SELECT city, popwhich columns you want back (SELECT * means all of them)
  • FROM citieswhich table to look in
  • WHERE region = 'Visayas'keep only rows where the condition is true, like a pandas mask
  • ORDER BY pop DESCsort by population, biggest first (DESC = descending)
  • the answeron the lab's four-city table this returns two rows: Cebu 964000, then Iloilo 457000
Query Straight Into pandas

read_sql hands back a DataFrame

Connect to the database, pass your query as a string, and the result arrives as a DataFrame — ready for every pandas move from the last three weeks.

A connection is an open line between your program and the database: you open it once, send queries through it, and close it when you are done.

import sqlite3 conn = sqlite3.connect("cities.db") df = pd.read_sql( "SELECT * FROM cities", conn)
  • import sqlite3SQLite support, built into Python: a database that lives in a single file
  • sqlite3.connect("cities.db")open a connection to the database file cities.db
  • pd.read_sql(query, conn)send the query through the connection and turn the rows that come back into a DataFrame
Same Ideas, Two Dialects

You already know SQL, in pandas form

TaskSQLpandas
Pick columnsSELECT a, bdf[["a","b"]]
Filter rowsWHERE x > 5df[df.x > 5]
SortORDER BY xsort_values("x")
Summarise per groupGROUP BY rgroupby("r")
SQL On The Flood Data

The same groupby, in another language

You already know what this query does — you wrote it in pandas in week 7. Recognising the equivalence is most of learning SQL.

  • COUNT(*) AS ncount the rows in each group and call the column n
  • GROUP BY regionone pile per region, exactly like groupby("region")
  • ORDER BY total DESC LIMIT 3biggest total first, keep only the top 3

The database does the work

With a 34,079-row table it makes little difference. At 34 million it is the difference between a query and an out-of-memory error.

# SQL SELECT region, COUNT(*) AS n, SUM(budget) AS total FROM projects GROUP BY region ORDER BY total DESC LIMIT 3
# pandas (df.groupby("region") .agg(n=("contract_id","count"), total=("budget","sum")) .nlargest(3, "total")) Region III 5412 267.7B National Capital Region 3925 159.2B Region V 2814 155.6B
Straight Into pandas

read_sql hands you a DataFrame

The boundary between the two worlds is one function. Query in SQL, analyse in pandas, and never write a loop over a cursor (the low-level object that hands back database rows one at a time).

Parameterise, always

Never build a query by string concatenation (gluing text together). Pass parameters — it is both safer and faster, because the database can reuse its plan (its worked-out way of running that query).

A parameter here is a ? placeholder in the query whose value you pass separately; the database always treats it as data, never as SQL. SQL injection is the attack where typed-in text sneaks extra SQL into a query.

import sqlite3 con = sqlite3.connect("flood.db") df = pd.read_sql( "SELECT * FROM projects WHERE region = ?", con, params=["Region III"]) # NEVER this: # f"... WHERE region = '{user_input}'" # that is an SQL injection, and it is # also how you get a quote-mark bug
  • WHERE region = ?a placeholder: "a value goes here"
  • params=["Region III"]the value for the ?, sent separately and safely
  • f"…'{user_input}'"pasting text straight into the query: a name like O'Brien breaks it, and malicious text can change it
Write A Table Back

to_sql for the results you want to keep

A cleaned table is worth storing. SQLite is a single file with no server to run, which makes it the easiest durable option you have.

if_exists is the argument to get right

replace drops and rebuilds; append adds. Choosing wrong duplicates every row or destroys the table.

cleaned.to_sql("projects_clean", con, index=False, if_exists="replace") 34,079 rows written, 12.13 MB # then anyone can query it, including # you next month pd.read_sql("SELECT COUNT(*) FROM projects_clean", con) 34079
  • cleaned.to_sql("projects_clean", con, …)save the DataFrame as a table called projects_clean inside the database
  • index=Falsedo not store pandas' row numbers as an extra column
  • SELECT COUNT(*) … → 34079ask the database how many rows the new table has: all of them arrived
SQL Joins

The same idea as merge, different spelling

A JOIN is SQL's word for merge. You learned this in week 7 as merge. The join types map one to one, and so do the traps — a duplicated key multiplies rows here too.

LEFT JOIN is the safe default

Same reasoning as how="left": it keeps your main table intact and makes unmatched keys visible as NULL.

# SQL SELECT p.contract_id, p.budget, r.island_group FROM projects p LEFT JOIN regions r ON p.region = r.region
# pandas projects.merge( regions, on="region", how="left") # INNER <-> how="inner" # LEFT <-> how="left" # FULL <-> how="outer"
  • FROM projects pp is a short nickname for the projects table, used as p.budget
  • LEFT JOIN regions r ON p.region = r.regionkeep every project and bring in the matching region row; the key is the region column
  • NULLSQL's word for a missing value (pandas shows it as NaN)
Quick Check

Tap to reveal

What does pd.read_sql(query, conn) return?

A · a raw SQL string
B · a DataFrame
C · a database connection
D · a list of tuples

B — a DataFrame.

read_sql runs the query on the connection and loads the rows straight into a DataFrame, so your pandas skills carry on from there.

Break

  Five minutes

Back to pull data live off the web.

Part C

APIs

Fetch fresh data over the web.

Files and databases usually sit on your machine or your organisation's network. This part fetches data from someone else's server over the internet, through an API, and turns the JSON reply into a DataFrame.

The Mental Model

A URL you ask, that answers in JSON

your program requests.get(url) API server holds the data GET request → ← JSON response then: pd.DataFrame(response.json())
Making The Call

requests.get, then check the status

Send the request, confirm it worked (200 means OK), then parse the body with .json(). Always check the status before trusting the data.

A status code is the three-digit number at the top of every response. 2xx means it worked, 4xx means your request was wrong (404 = not found, 429 = too many requests), 5xx means the server failed.

Like a courier's tracking status: "delivered", "address not found", "try again later". Read it before you open the parcel.

import requests r = requests.get("https://api.site.com/cities") r.status_code # 200 = OK data = r.json() # parsed to dict/list
  • import requeststhe standard Python library for HTTP (install it with pip if missing)
  • requests.get(url)send a GET request to that address and wait for the response, stored in r
  • r.status_codethe number the server replied with; 200 = OK
  • r.json()read the body as JSON: JSON lists become Python lists, JSON objects become dictionaries
Read The Number First

Six status codes cover almost everything you will meet

CodeNameWhat it means for youWhat to check first
200OKit worked; the body holds your datago ahead: r.json()
400Bad Requestthe server did not understand what you askedthe parameters in the address
401 / 403Unauthorized / Forbiddenyou are not allowed inyour API key: missing, mistyped, expired
404Not Foundnothing lives at that addressthe spelling of the endpoint
429Too Many Requestsyou hit the rate limitwait (Retry-After), then cache
500Internal Server Errorthe server broke, not youtry again later
Back On Familiar Ground

JSON in, DataFrame out

A list of records drops straight into a DataFrame. From here it's ordinary pandas — filter, group, plot — exactly as if you'd loaded a CSV.

r = requests.get(url) df = pd.DataFrame(r.json()) df.head() # same pandas as always
  • pd.DataFrame(r.json())a list of dictionaries becomes a table: one row per dictionary, one column per key
  • e.g.[{"city": "Cebu", "pop": 964000}, {"city": "Davao", "pop": 1777000}] becomes 2 rows with columns city and pop
The Course API

You already have one to practise on

This portal serves the flood dataset at /api/flood — an endpoint. Same data you have been loading from disk — now through the door a real service would use.

It is self-describing

Hit the root and it tells you its endpoints, its licence, and its data-quality warnings. That is what a good API does.

import requests r = requests.get("https://portal.latarak.com/api/flood") r.status_code 200 r.json()["rows"] 34079 r.json()["data_quality_warning"] {'field': 'amount_paid', 'note': 'This field is 0 on EVERY row... It does NOT mean these projects were unpaid.'}
  • r.status_code → 200the request worked
  • r.json()["rows"]the reply is a dictionary; look up its "rows" key: 34,079 projects available
  • ["data_quality_warning"]another key: the API warns you that one column is unreliable
Paging

A big collection arrives a page at a time

No sensible API returns 34,079 records in one response. It gives you a window and a link to the next one. This is called paging (or pagination), like the pages of search results.

Follow next until it is null

Do not compute page numbers yourself. The server knows how many there are; your arithmetic can drift.

url = BASE + "/api/flood/projects?limit=100" rows = [] while url: r = requests.get(url, headers=HEAD).json() rows.extend(r["results"]) url = r["next"] # None at the end df = pd.DataFrame(rows) len(df) 34079
  • ?limit=100a query parameter added to the address: "100 records per page"
  • while url:keep looping as long as there is a next page (None counts as false and ends the loop)
  • rows.extend(r["results"])add this page's records to the growing list
  • url = r["next"]the server tells you the address of the next page
Be A Good Client

Rate limits, keys, and not hammering someone else’s server

A rate limit is the most requests a server accepts from you per minute. An API key is a password-like code that identifies you to the API. The flood API allows 20 requests a minute anonymously and 200 with the demo key. Exceed it and you get a 429 with a Retry-After header telling you exactly how long to wait.

Cache what you fetch

Pull once, save to disk, then work from the file. Re-downloading the same data on every notebook run is the most common way students get blocked.

HEAD = {"X-API-Key": "latarak-demo"} r = requests.get(url, headers=HEAD) if r.status_code == 429: wait = int(r.headers["Retry-After"]) time.sleep(wait) # and cache it if not Path("cache.parquet").exists(): df = fetch_all() df.to_parquet("cache.parquet") df = pd.read_parquet("cache.parquet")
  • HEAD = {"X-API-Key": …}a header is extra information sent with the request; this one carries your key
  • if r.status_code == 429:429 = "too many requests": you hit the rate limit
  • time.sleep(wait)pause for the number of seconds the server asked for (time is a built-in module: import time first; fetch_all() stands for your own function wrapping the paging loop from the last slide)
  • if not Path(…).exists():a cache: only download if you do not already have a saved copy
Network Calls Fail

Wrap them, or your notebook dies at cell 12

A timeout is how long to wait for a reply before giving up. A file read fails immediately or not at all. A network call can hang, time out, return HTML instead of JSON, or succeed with an error body.

Always set a timeout

Without one, requests waits forever. A hung cell is worse than a failed one, because it looks like it is working.

try: r = requests.get(url, timeout=10) r.raise_for_status() # 4xx/5xx -> raise data = r.json() except requests.Timeout: print("server too slow — using cache") except requests.HTTPError as e: print("status", e.response.status_code) except ValueError: print("not JSON — got HTML?") print(r.text[:200])
  • try: … except …:week 4: try the risky lines; if one of the named errors happens, run that except block instead of crashing
  • timeout=10give up after 10 seconds without a reply
  • r.raise_for_status()turn a 4xx or 5xx status code into an error (HTTPError) you can catch
  • except ValueError:the body was not valid JSON — often an HTML error page; print the start of it to see
Use APIs Responsibly

Someone else runs that server

Quick Check

Tap to reveal

An API returns 429 with a Retry-After: 37 header. What should your code do?

A · Retry immediately — the request probably just failed
B · Wait 37 seconds, then retry; and cache results so you stop re-fetching
C · Switch to a different API
D · Send the requests in parallel to finish before the limit applies
B. 429 means you exceeded the rate limit, and the server has told you exactly how long to wait. Retrying immediately (A) or parallelising (D) makes it worse and is how you get blocked. Caching to disk is the real fix: fetch once, then work from the file.
Quick Check

Tap to reveal

A request returns r.status_code == 200. What does that mean?

A · the server errored
B · the request succeeded
C · the page was not found
D · you were rate-limited

B — the request succeeded.

200 means OK. 404 is not found, 429 is too many requests, and 500 is a server error. Check before calling .json().

Your Turn · 8 min

Three doors, one DataFrame

1 · Benchmark it yourself

Load the flood CSV. Save it as CSV, JSON and SQLite. Compare file sizes with os.path.getsize — do you reproduce the benchmark table from Part A?

2 · Query it

Write it to SQLite. Get total budget per region in SQL, then the same in pandas. Confirm the numbers match.

3 · Fetch it

Pull 3 pages from /api/flood/projects with the demo key. Build a DataFrame from the results.

4 · Read the warning

Request /api/flood and print data_quality_warning. What wrong headline would someone write if they trusted that column?

One Table, Five Doors

Everything this week, at a glance

SourceRead it withWatch out for
CSVpd.read_csvNo dtypes; encoding; use usecols
Parquetpd.read_parquetNeeds pyarrow installed — not on every server
Excelpd.read_excelsheet_name; BOM → utf-8-sig
SQLpd.read_sqlParameterise — never f-string a query
APIrequests + pd.DataFrameTimeout, 429s, paging, and cache the result
This Week's Lab

Three doors into one DataFrame

You'll read a JSON and an Excel file, query a small SQLite database, and fetch data from a public API — landing each as a DataFrame you summarise. ~45 minutes.

You'll practise

read_json/read_excel, read_sql, and requests.get().json().

Stretch, if you want

Add a WHERE clause so the database, not pandas, does the filtering.

Recap

Many sources, one DataFrame

Glossary

Every new word from today, in one line each

Files and databases

file format
the layout of data inside a file: CSV, JSON, Excel, Parquet
CSV
plain text, commas between cells; stores no types
JSON
text shaped like Python lists and dictionaries
Parquet
compact column-based format that remembers each column's type
encoding
the codebook from bytes to letters (utf-8, latin-1)
mojibake
garbled letters (ñ) from reading with the wrong encoding
file path
a file's address: folders, then the file name
chunk
one piece of a big file, read and processed at a time
database / table
a store of named tables / one grid of rows and columns in it
SQL query
one question in SQL: SELECT columns FROM table WHERE condition
connection
the open line to a database that queries travel through
parameter (?)
a placeholder filled safely with a value; prevents SQL injection

Web APIs

API / endpoint
a web address for programs / one address serving one kind of data
HTTP
the rules programs use to talk to web servers
request / response
the message you send / the reply that comes back
status code
the reply's three-digit verdict: 200 OK, 404 not found, 429 slow down
API key
a password-like code that identifies you; keep it out of your code
rate limit
the most requests a server accepts from you per minute
paging
getting a big result one page at a time, following "next"
timeout
how long to wait for a reply before giving up
cache
a saved copy, so you do not download the same data twice
JOIN
SQL's word for merge: combine tables on a key
Before Next Week

Practice & reading

Next Week

Text Processing & Regular Expressions

Cleaning, searching, and extracting from messy text — phone numbers, dates, codes — with Python's string tools and regex.

DS 208 · Programming for Data Science