Writing / Technical / Build Log · 27 OCT 2026
From Files to Tables, Building a Data Loader You Can Run Twice
Moving a synthetic town from CSV files into PostgreSQL. How I designed a loader that can run twice without duplicates, the transaction bug my tests caught that manual runs never showed, and the SQL I wrote once the data was in.
In the last post, my project ended with a folder of CSV files which included a synthetic town of 1,000 people and about 60,000 bank transactions.
The thing about files is that they are useful, but they're like letters in a drawer. You can read them one at a time. You can't easily ask, "show me every salary paid in March to anyone who arrived in 2024."
A database can answer that in milliseconds. So this stage of the Newcomer Credit Data Platform is about moving data from files into PostgreSQL. It sounds like the most boring step in data engineering. It's actually where a lot of pipelines quietly go wrong. You end up with duplicated rows, half-loaded files, data nobody can trace back to its source, and failures that leave no record.
This post covers how I designed the loading step to avoid all of that, the transaction bug my tests caught that manual runs never showed, and the SQL I wrote once the data was in.
Series so far:
- Before the First Pipeline: setting up the machine and project base
- Fake Data, Real Discipline: generating a seeded synthetic population
- From Files to Tables (this post)
The code is on GitHub.
What I ended up with
Loading the data:
$ make load
customers run 1: 1,000 inserted, 0 already loaded
bank_transactions run 2: 59,801 inserted, 0 already loaded
applications run 3: 1,000 inserted, 0 already loaded
Loading it again:
$ make load
customers run 4: 0 inserted, 1,000 already loaded
bank_transactions run 5: 0 inserted, 59,801 already loaded
applications run 6: 0 inserted, 1,000 already loaded
Nothing changed the second time, and both attempts are on record. A file that's been tampered with, reshaped or corrupted is refused loudly, logged as failed, and leaves zero partial rows behind. Twenty-two tests prove all of it, including ten that run against a real PostgreSQL database without ever touching the real data.
Part 1: Six ideas behind the loader
Before any code, these are the ideas the whole design rests on. Each one applies to loading anything. And that includes files, API responses, database extracts.
1. The loading dock
Warehouses don't carry deliveries straight onto the shelves. Trucks unload at a loading dock, goods get checked against the delivery note, and only then do they move to the right shelf.
My loader works the same way. It copies each CSV into a temporary table where every column is plain text, exactly as it arrived. Then it converts and checks the data on the way into the real table. The temporary table disappears afterwards.
Why land everything as text first? Because the file is still untrusted. Loading text never fails on a strange value, so all the converting and checking happens in one controlled place.
2. One truck, not sixty thousand trips
There are two ways to get 60,000 rows into a database. You can send 60,000 separate INSERT statements, which is like moving house by carrying each item to the new place one at a time. Or you can use PostgreSQL's COPY command, which streams the whole file in one go, like loading everything into a single truck.
COPY is often tens of times faster. On real volumes, that's the difference between a load that takes seconds and one that takes hours.
3. The guest list at the door
Every table has a primary key. This is a column whose value must be unique, like a national ID number. transaction_id is the primary key of the transactions table.
When the loader moves rows from the dock to the shelf, it says ON CONFLICT (transaction_id) DO NOTHING. Think of a bouncer with a guest list. If someone with that ID is already inside, they simply don't get in again. No error, no fuss.
That single line makes the loader idempotent. If you run it once or ten times, the result remains the same. Pipelines get retried constantly, after network blips, timeouts and crashes. If a retry can create duplicates, every failure becomes a manual clean-up job.
4. All or nothing
A bank transfer has two halves: money leaves one account and arrives in another. If a system crashed between them, money would vanish. So the database groups the steps into a transaction. Either every step happens, or none of them do.
My loader puts each file's rows and its "succeeded" status into one transaction. If row 45,000 has a broken value, the entire file rolls back. There's never half a file loaded with no record of what happened.
5. The flight recorder
Planes carry a flight recorder so investigators can reconstruct exactly what happened. My loader keeps one too. That's the table called raw.ingestion_runs, with one row per load attempt. It records which file was loaded, its fingerprint, how many rows went in, how many were skipped, and whether the attempt succeeded or failed, with the error.
It writes a running entry before loading, in its own committed transaction. So even a load that crashes leaves a record behind.
6. Check the seal and the shape before opening the box
Before loading a single row, the loader checks two things.
The seal. The generator writes a manifest.json containing a SHA-256 fingerprint of every file. The loader recomputes the fingerprint and compares. If it doesn't match, the file changed after it was produced. The classic cause is someone opening a CSV in Excel and saving it, which silently reformats dates, strips leading zeros and changes number formats.
The shape. The loader checks the header has exactly the expected columns, in order. If a source renames a column, the load stops instead of putting data in the wrong places.
Both checks fail loudly. A pipeline that stops and complains is annoying. A pipeline that silently loads wrong data is dangerous.
Part 2: Designing the landing tables
The raw schema is the delivery shelf from part one: data lands here as received and is never edited afterwards. Here's the full table definition, then each decision behind it.
sql/02_raw_tables.sql
-- 02_raw_tables.sql
-- The landing tables for generated data, plus an audit log of every load.
-- Run as ncp_dev inside newcomer_credit: make tables
-- Safe to re-run: IF NOT EXISTS skips tables that already exist.
-- ---------------------------------------------------------------------------
-- The load log. One row per file, per load attempt, whether it worked or not.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS raw.ingestion_runs (
run_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
source_name TEXT NOT NULL, -- e.g. 'bank_transactions'
file_path TEXT NOT NULL,
file_sha256 TEXT NOT NULL, -- fingerprint of the exact file loaded
generator_version TEXT, -- from manifest.json
generator_seed INTEGER, -- from manifest.json
rows_in_file INTEGER,
rows_inserted INTEGER,
rows_skipped INTEGER, -- already loaded before
status TEXT NOT NULL
CHECK (status IN ('running', 'succeeded', 'failed')),
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
-- ---------------------------------------------------------------------------
-- Landing tables. Columns match the CSV files, plus two lineage columns:
-- _run_id which load brought this row in
-- _loaded_at when it arrived
-- No foreign keys here on purpose: real sources arrive in any order.
-- Relationships get tested later, in dbt, instead of enforced at the door.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS raw.customers (
customer_id TEXT PRIMARY KEY,
arrival_date DATE NOT NULL,
income_type TEXT NOT NULL,
_run_id BIGINT NOT NULL,
_loaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS raw.bank_transactions (
transaction_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL, -- exact money: never FLOAT
currency CHAR(3) NOT NULL,
counterparty TEXT NOT NULL,
description TEXT,
event_at TIMESTAMPTZ NOT NULL, -- when it happened
booked_at TIMESTAMPTZ NOT NULL, -- when the bank recorded it
available_at TIMESTAMPTZ NOT NULL, -- when the lender could see it
_run_id BIGINT NOT NULL,
_loaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS raw.applications (
application_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
decided_at TIMESTAMPTZ NOT NULL,
_run_id BIGINT NOT NULL,
_loaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Speeds up the most common question later: "this customer's transactions, in time order".
CREATE INDEX IF NOT EXISTS ix_bank_transactions_customer_event
ON raw.bank_transactions (customer_id, event_at);
The types
Each column's type is a promise about what it holds, and PostgreSQL enforces it on every row.
| Type | What it holds | Where I use it |
|---|---|---|
TEXT |
Any text, any length | IDs, names, descriptions |
DATE |
A calendar date, no time | arrival_date |
NUMERIC(12,2) |
An exact decimal: up to 12 digits, 2 after the point | amount |
CHAR(3) |
Exactly three characters | currency |
TIMESTAMPTZ |
A moment in time, time zone aware | every timestamp |
INTEGER / BIGINT |
Whole numbers (BIGINT goes much higher) |
counts and IDs |
Money is never FLOAT. Floating-point numbers can't store most decimals exactly; in most languages, 0.1 + 0.2 comes out as 0.30000000000000004. With money, those tiny errors compound into totals that don't reconcile. NUMERIC is exact.
Timestamps are always TIMESTAMPTZ. It stores an exact moment, independent of where you're viewing it from. Plain TIMESTAMP stores a clock reading with no time zone, which is ambiguous the moment data crosses regions.
The constraints
Constraints are rules the database checks on every row, forever, for free.
transaction_id TEXT PRIMARY KEY,
Unique and never empty. It's what the ON CONFLICT bouncer checks against.
amount NUMERIC(12,2) NOT NULL,
Must always have a value.
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
Only these values are allowed. A typo like 'succeded' is rejected by the database itself, not by hoping the code is right.
run_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
PostgreSQL numbers the rows itself: 1, 2, 3 and onwards. Nobody supplies a run_id by hand.
_loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
Filled automatically with the current time if no value is given.
The lineage columns
Every landing table carries two columns that aren't in the source file:
_run_id: which load brought this row in, pointing to a row iningestion_runs._loaded_at: when it arrived.
The leading underscore is a convention meaning "added by the pipeline, not from the source." With these two columns, any single transaction traces back to the exact file, fingerprint and moment it came from. That traceability is called lineage, and in regulated industries like lending, being able to answer "where did this number come from?" isn't optional.
What's deliberately missing is the foreign keys
A foreign key would force every customer_id in the transactions table to already exist in the customers table. That sounds sensible, but real sources don't arrive politely in order. A transactions file can land before the customers file it refers to. If the raw layer enforced foreign keys, loads would fail for reasons that aren't real errors.
So the raw layer accepts what arrives, and relationships are tested downstream instead of enforced at the door. The rule I'm keeping: raw is for recording what arrived. Judging it comes later.
The index
CREATE INDEX IF NOT EXISTS ix_bank_transactions_customer_event
ON raw.bank_transactions (customer_id, event_at);
An index is the index at the back of a book. Without it, finding one customer's transactions means reading all 60,000 rows. With it, PostgreSQL jumps straight to the right page. I built it on the question this project asks most often: "this customer's transactions, in time order."
Creating the tables
make tables
make tables runs the file with psql. Because every statement uses IF NOT EXISTS, running it twice is harmless, the same idempotent setup principle from part one.
Part 3: The loader, step by step
The loader is one Python file, ingestion/load_generated.py, run with make load. The full file is at the end of this post. Here are the parts that matter.
A recipe for each source
SOURCES = {
"customers": {
"key": "customer_id",
"columns": ["customer_id", "arrival_date", "income_type"],
"casts": ["customer_id", "arrival_date::date", "income_type"],
},
"bank_transactions": {
"key": "transaction_id",
"columns": ["transaction_id", "customer_id", "amount", "currency", "counterparty",
"description", "event_at", "booked_at", "available_at"],
"casts": ["transaction_id", "customer_id", "amount::numeric(12,2)", "currency",
"counterparty", "description", "event_at::timestamptz",
"booked_at::timestamptz", "available_at::timestamptz"],
},
...
}
For each table: its primary key, the exact columns expected in the file, and how to convert each one from text to its real type. :: is PostgreSQL's conversion operator, so '1770.00'::numeric(12,2) turns text into an exact number.
The generator's hidden "answer key" file isn't in this list. The surest way to keep sensitive data out of a table is for the loader to have no idea it exists.
Step 1: log the attempt before anything else
def start_run(conn, schema, name, path, digest, manifest) -> int:
"""Log the attempt and COMMIT it, so even a failed load leaves a record."""
with conn.transaction():
row = conn.execute(
f"""
INSERT INTO {schema}.ingestion_runs
(source_name, file_path, file_sha256, generator_version,
generator_seed, rows_in_file, status)
VALUES (%s, %s, %s, %s, %s, %s, 'running')
RETURNING run_id
""",
(name, str(path), digest, manifest.get("generator_version"),
manifest.get("seed"), manifest["files"][name]["rows"]),
).fetchone()
return row[0]
The running row gets its own transaction, so it's committed immediately. RETURNING run_id hands back the number PostgreSQL just assigned, which every loaded row will carry.
Steps 2 and 3: check the seal and the shape
if digest != expected["sha256"]:
raise LoadError(
f"{path.name}: checksum does not match manifest.json. "
"The file changed after it was generated (was it opened and saved in Excel?)."
)
header = read_header(path)
if header != spec["columns"]:
raise LoadError(
f"{path.name}: unexpected columns.\n expected {spec['columns']}\n found {header}"
)
raise stops everything immediately. LoadError is a custom error type, which lets the tests check for exactly this kind of failure rather than any failure at all. Notice the messages say what's wrong and suggest the likely cause. Error messages are written for the person reading them at 3am.
Step 4: the loading dock
with conn.transaction():
conn.execute(
f"CREATE TEMP TABLE tmp_load ({', '.join(c + ' TEXT' for c in spec['columns'])}) "
"ON COMMIT DROP"
)
with conn.cursor().copy(
f"COPY tmp_load ({cols}) FROM STDIN WITH (FORMAT csv, HEADER true)"
) as copy:
with path.open("rb") as f:
while chunk := f.read(1024 * 1024):
copy.write(chunk)
A temporary table of plain text columns matching the file. ON COMMIT DROP means it vanishes when the transaction ends. COPY ... FROM STDIN streams the file into it in one-megabyte chunks, so even a huge file never needs to fit in memory.
Step 5: onto the shelf, past the bouncer
inserted = conn.execute(
f"""
INSERT INTO {schema}.{name} ({cols}, _run_id)
SELECT {casts}, %s FROM tmp_load
ON CONFLICT ({spec['key']}) DO NOTHING
""",
(run_id,),
).rowcount
INSERT ... SELECT copies rows from the dock into the real table, converting types as it goes. ON CONFLICT ... DO NOTHING skips rows already there. .rowcount reports how many actually went in, and everything else counts as skipped.
%s is a placeholder, and it matters. The run_id value is passed separately, and the database driver inserts it safely. Values should never be pasted into SQL text with string formatting, because that's how SQL injection happens: input crafted to rewrite your query. The table and column names in the f-string come from my own SOURCES dictionary, never from outside input, which is the only reason they're formatted in directly.
Step 6: record the outcome
conn.execute(
f"""
UPDATE {schema}.ingestion_runs
SET status = 'succeeded', rows_inserted = %s, rows_skipped = %s,
finished_at = now()
WHERE run_id = %s
""",
(inserted, rows_in_file - inserted, run_id),
)
Still inside the same transaction as the insert. Rows and status land together, or neither does.
If anything goes wrong at any point:
except Exception as exc:
finish_failed(conn, schema, run_id, str(exc))
raise
The data transaction has already rolled back automatically. finish_failed marks the run as failed with the error message, in a fresh transaction. Then raise re-throws the error so the program stops loudly instead of carrying on as if nothing happened.
Part 4: Proving it works
First load, second load
make seed
make load
make load
The first load inserts everything. The second inserts nothing. Then the flight recorder, from inside the database:
make psql
SELECT run_id, source_name, status, rows_in_file, rows_inserted, rows_skipped
FROM raw.ingestion_runs
ORDER BY run_id;
run_id | source_name | status | rows_in_file | rows_inserted | rows_skipped
--------+-------------------+-----------+--------------+---------------+--------------
1 | customers | succeeded | 1000 | 1000 | 0
2 | bank_transactions | succeeded | 59801 | 59801 | 0
3 | applications | succeeded | 1000 | 1000 | 0
4 | customers | succeeded | 1000 | 0 | 1000
5 | bank_transactions | succeeded | 59801 | 0 | 59801
6 | applications | succeeded | 1000 | 0 | 1000
make psql is a small convenience in the Makefile: it opens psql as the project user, in the project database, with the settings from .env already loaded.
"It doesn't create duplicates" is a claim. Two loads, identical counts and a log showing every row skipped the second time is proof.
Breaking it on purpose
A tampered file. I changed one word in customers.csv and saved it, the way an Excel edit would:
LoadError: customers.csv: checksum does not match manifest.json. The file changed after it was generated (was it opened and saved in Excel?).
SELECT run_id, source_name, status, error_message
FROM raw.ingestion_runs
WHERE status = 'failed';
The failed attempt is logged with its reason, and nothing in raw.customers changed. Regenerating with the same seed produced byte-identical files, and the next load went through cleanly with nothing new inserted.
A renamed column. With the header changed from decided_at to decision_time:
LoadError: applications.csv: unexpected columns.
expected ['application_id', 'customer_id', 'decided_at']
found ['application_id', 'customer_id', 'decision_time']
A broken value halfway through a file. With one amount replaced by not-a-number, the conversion fails, the whole file rolls back, and the table holds zero rows from it. All or nothing, as designed.
Part 5: The bug my tests caught
The loader passed every run by hand. Twice. Then the test suite failed:
psycopg.errors.DuplicateTable: relation "tmp_load" already exists
The temporary table was supposed to vanish when each file's transaction committed. Somehow, it was still there for the next load.
The cause was a default in psycopg, the Python driver for PostgreSQL. Out of the box, psycopg opens a transaction the moment you run any query, and keeps it open until you explicitly commit. One test had run a simple SELECT count(*) before loading. That quietly opened a transaction. From then on, every "transaction" block the loader started was actually nested inside it, as a savepoint, and nothing truly committed. The temporary table never dropped, and the second load crashed into it.
The worse part is what the error was hiding. The succeeded statuses weren't really committed either. On a real system, a later crash would have rolled back loads that everyone believed had finished, and the log would have lied.
The manual runs never showed this, because the command-line path happened not to run any plain query before its first transaction.
The fix was one setting:
with psycopg.connect(autocommit=True) as conn:
With autocommit on, each plain query commits by itself, and each with conn.transaction() block is a real transaction that commits when it ends. Then I made sure it can't happen again:
def load_all(conn, data_dir: Path, schema: str = "raw") -> list[dict]:
if not conn.autocommit:
raise LoadError("Connect with psycopg.connect(autocommit=True) before loading.")
The loader now refuses to run on an unsafe connection, and there's a test proving it refuses.
What I took from it:
- A manual run proves one path works. Tests try paths you didn't think of.
- Know your database driver's transaction behaviour. "Implicit transactions" is one of those defaults that's harmless until it isn't.
- When a bug is fixed, add a guard and a test, so the fix can't silently disappear later.
Part 6: Testing a database without wrecking it
Tests that touch a real database raise a fair worry: will running them damage the real data? Mine use three techniques to make sure they can't.
A throwaway schema for every test. Each test creates a schema with a random name like test_raw_3f9c1a, builds the tables inside it from the same 02_raw_tables.sql file, runs, and drops the schema afterwards:
@pytest.fixture
def schema(conn):
"""A fresh, empty copy of the raw tables in a uniquely named schema."""
name = f"test_raw_{uuid.uuid4().hex[:6]}"
with conn.transaction():
conn.execute(f"CREATE SCHEMA {name}")
conn.execute(DDL.replace("raw.", f"{name}."))
yield name
with conn.transaction():
conn.execute(f"DROP SCHEMA {name} CASCADE")
It's a rehearsal on a spare stage, never the main one. Because the test tables come from the same SQL file as the real ones, the tests always check the real design. A pytest fixture is setup code that runs before a test; everything after yield runs afterwards as cleanup.
Small, fresh data for every test. Another fixture generates a town of 20 customers into a temporary folder, so tests are fast and independent of whatever is in data/generated/.
A graceful skip. If PostgreSQL isn't reachable, the database tests are skipped with the reason instead of failing:
try:
connection = psycopg.connect(connect_timeout=3, autocommit=True)
except psycopg.OperationalError as exc:
pytest.skip(f"PostgreSQL not reachable: {exc}")
With the database off, make test reports 12 passed, 10 skipped: the generator tests still run, and the skip tells you exactly why the others didn't.
What the ten loader tests promise
| Test | The promise |
|---|---|
test_refuses_a_connection_without_autocommit |
The loader won't run on an unsafe connection |
test_loads_every_row_the_manifest_promises |
Database row counts match the manifest exactly |
test_loading_twice_creates_no_duplicates |
Idempotency |
test_every_load_attempt_is_logged |
Two loads mean six logged runs, all succeeded |
test_every_row_points_to_the_run_that_loaded_it |
Lineage: no row without a successful run behind it |
test_money_survives_the_load_exactly |
The database total equals the file total, to the penny |
test_timestamps_keep_their_utc_meaning |
A timestamp means the same moment before and after loading |
test_tampered_file_is_refused_and_logged |
Checksum mismatch: refused, logged, nothing loaded |
test_renamed_column_is_refused |
A changed header stops the load |
test_bad_value_rolls_back_the_whole_file |
One bad value means zero rows from that file |
The money test is worth showing, because it checks the whole chain from file to database with exact decimals:
def test_money_survives_the_load_exactly(conn, schema, data_dir):
load_all(conn, data_dir, schema)
lines = (data_dir / "bank_transactions.csv").read_text().splitlines()[1:]
expected = sum(Decimal(line.split(",")[2]) for line in lines)
loaded = conn.execute(f"SELECT sum(amount) FROM {schema}.bank_transactions").fetchone()[0]
assert loaded == expected
Decimal is Python's exact decimal type, the counterpart of PostgreSQL's NUMERIC.
Fire drills
A test that has never failed hasn't proven anything yet, so I removed protections on purpose:
| Planted bug | Tests that failed |
|---|---|
Deleted the ON CONFLICT ... DO NOTHING line |
3, led by test_loading_twice_creates_no_duplicates |
| Disabled the checksum check | 1: test_tampered_file_is_refused_and_logged |
Each time, the right test caught it and its name said exactly what had broken. I also confirmed the real raw.ingestion_runs table had the same number of rows before and after a full test run, so the isolation genuinely works.
Part 7: Querying the data
With the data in PostgreSQL, the questions get interesting. These are the queries I ran, each one teaching a different SQL idea.
First: what time zone am I looking at?
SET timezone = 'UTC';
My Mac is set to Nairobi time, so by default psql shows the first transaction like this:
transaction_id | amount | event_at
----------------+---------+------------------------
TXN-00000001 | 1770.00 | 2024-02-25 09:43:00+03
After SET timezone = 'UTC':
TXN-00000001 | 1770.00 | 2024-02-25 06:43:00+00
Same moment, different clock on the wall. TIMESTAMPTZ stores the moment; the session's time zone only controls how it's displayed. It also controls functions like date_trunc, which decides where "a month" starts, so for analysis I set UTC first.
1. How big is everything? (UNION ALL)
SELECT 'customers' AS table_name, count(*) AS row_count FROM raw.customers
UNION ALL
SELECT 'bank_transactions', count(*) FROM raw.bank_transactions
UNION ALL
SELECT 'applications', count(*) FROM raw.applications;
table_name | row_count
-------------------+-----------
customers | 1000
bank_transactions | 59801
applications | 1000
UNION ALL stacks results from several queries. Plain UNION also removes duplicate rows, which costs extra work and is rarely what you want.
2. The latest successful load per source (DISTINCT ON)
SELECT DISTINCT ON (source_name)
source_name, run_id, status, rows_inserted, rows_skipped
FROM raw.ingestion_runs
WHERE status = 'succeeded'
ORDER BY source_name, run_id DESC;
DISTINCT ON (source_name) keeps only the first row for each source, and the ORDER BY decides which one is first: the newest. It's a PostgreSQL feature. Query 6 shows the version that works in any database.
3. Money in, money out (SUM ... FILTER)
SELECT customer_id,
SUM(amount) FILTER (WHERE amount > 0) AS money_in,
SUM(amount) FILTER (WHERE amount < 0) AS money_out,
SUM(amount) AS net
FROM raw.bank_transactions
GROUP BY customer_id
ORDER BY net DESC
LIMIT 5;
customer_id | money_in | money_out | net
-------------+----------+-----------+----------
CUST-00594 | 84000.00 | -28124.89 | 55875.11
CUST-00992 | 84000.00 | -31816.74 | 52183.26
CUST-00679 | 80880.00 | -30035.46 | 50844.54
CUST-00440 | 84000.00 | -33325.46 | 50674.54
CUST-00220 | 73500.00 | -26496.69 | 47003.31
GROUP BY makes one row per customer; FILTER limits which rows each sum includes. The portable equivalent is SUM(CASE WHEN amount > 0 THEN amount END).
4. One customer, month by month (date_trunc)
SELECT date_trunc('month', event_at) AS month,
COUNT(*) AS transactions,
SUM(amount) AS net
FROM raw.bank_transactions
WHERE customer_id = 'CUST-00001'
GROUP BY 1
ORDER BY 1
LIMIT 4;
month | transactions | net
------------------------+--------------+---------
2024-02-01 00:00:00+00 | 1 | 1770.00
2024-03-01 00:00:00+00 | 4 | 783.04
2024-04-01 00:00:00+00 | 4 | 801.39
2024-05-01 00:00:00+00 | 4 | 793.25
date_trunc('month', ...) rounds each timestamp down to the start of its month. GROUP BY 1 means "group by the first column in the SELECT." The first month has a single transaction because this customer arrived after that month's rent date, so only their salary landed.
5. Days between salaries (LAG)
SELECT event_at::date AS paid_on,
event_at::date - LAG(event_at::date) OVER (ORDER BY event_at) AS days_since_last
FROM raw.bank_transactions
WHERE customer_id = 'CUST-00001'
AND description LIKE 'SALARY%'
ORDER BY event_at
LIMIT 6;
paid_on | days_since_last
------------+-----------------
2024-02-25 |
2024-03-28 | 32
2024-04-27 | 30
2024-05-27 | 30
2024-06-27 | 31
2024-07-26 | 29
LAG fetches a value from the previous row, and OVER (ORDER BY event_at) defines what "previous" means. This is a window function: it calculates across neighbouring rows without collapsing them the way GROUP BY does. Subtracting two dates gives a number of days.
This exact pattern becomes the project's income regularity feature: how reliably does money arrive?
6. Each customer's latest salary (ROW_NUMBER in a CTE)
WITH ranked AS (
SELECT customer_id, amount, event_at,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY event_at DESC) AS rn
FROM raw.bank_transactions
WHERE description LIKE 'SALARY%'
)
SELECT customer_id, amount, event_at::date AS last_paid
FROM ranked
WHERE rn = 1
ORDER BY customer_id
LIMIT 3;
customer_id | amount | last_paid
-------------+---------+------------
CUST-00001 | 1770.00 | 2025-12-26
CUST-00002 | 2130.00 | 2025-12-28
CUST-00003 | 2590.00 | 2025-12-27
WITH ranked AS (...) is a common table expression (CTE): a named, temporary result that turns one tangled query into readable steps. ROW_NUMBER() numbers each customer's salaries from newest to oldest, and PARTITION BY customer_id restarts the count for every customer. Keeping rn = 1 gives the latest. The same "rank, then keep number one" pattern is the standard way to remove duplicates, which this project will need once deliberately messy data arrives.
7. Solving a puzzle from the last post
In the previous post I found about 860 more salary payments than rent payments, and explained it: customers who arrive after the 1st miss that month's rent but still get that month's salary. Here's the proof in SQL:
WITH per_customer AS (
SELECT customer_id,
COUNT(*) FILTER (WHERE description LIKE 'SALARY%') AS salaries,
COUNT(*) FILTER (WHERE description = 'RENT STANDING ORDER') AS rents
FROM raw.bank_transactions
GROUP BY customer_id
)
SELECT salaries - rents AS extra_salaries,
COUNT(*) AS customers
FROM per_customer
GROUP BY 1
ORDER BY 1;
extra_salaries | customers
----------------+-----------
0 | 141
1 | 859
Exactly 859 customers have one extra salary and 141 have none. Nobody has two. The explanation from the last post holds, and now it's a query anyone can rerun rather than a claim.
8. The idea that drives the whole project (a join with a time condition)
SELECT a.application_id,
a.decided_at::date,
COUNT(*) FILTER (WHERE t.available_at <= a.decided_at) AS known_at_decision,
COUNT(*) FILTER (WHERE t.available_at > a.decided_at) AS arrived_later
FROM raw.applications a
JOIN raw.bank_transactions t USING (customer_id)
GROUP BY a.application_id, a.decided_at
ORDER BY a.application_id
LIMIT 4;
application_id | decided_at | known_at_decision | arrived_later
----------------+------------+-------------------+---------------
APP-00001 | 2024-10-04 | 30 | 59
APP-00002 | 2025-12-27 | 40 | 1
APP-00003 | 2025-01-25 | 22 | 45
APP-00004 | 2025-11-13 | 44 | 6
JOIN ... USING (customer_id) matches each application with every transaction belonging to the same customer. a and t are aliases, short names for the tables.
Look at APP-00001. When that lending decision was made, 30 of the customer's transactions were known. Another 59 arrived afterwards. A feature that accidentally used all 89 would be using the future to explain the past. It would look brilliant in testing and fail in real life.
That available_at <= decided_at condition is called point-in-time correctness, and it's the most important line in this entire project. The next stage is built around it.
9. Did my index earn its place? (EXPLAIN ANALYZE)
EXPLAIN ANALYZE
SELECT * FROM raw.bank_transactions
WHERE customer_id = 'CUST-00500'
ORDER BY event_at;
Index Scan using ix_bank_transactions_customer_event on bank_transactions
(actual time=0.036..0.053 rows=90 loops=1)
Execution Time: 0.413 ms
EXPLAIN ANALYZE runs the query and shows how PostgreSQL executed it. Index Scan means it used my index to jump straight to that customer's 90 rows instead of reading all 59,801. Under half a millisecond. When a query is slow, this is the first command to reach for.
Two psql tips. \timing on shows how long every query takes. And if a long result fills the screen with a : at the bottom, you're in the pager: press q to get back.
Prefer a GUI? In VS Code, the SQLTools extension with its PostgreSQL driver runs these queries in the editor, with results in a table. DBeaver and pgAdmin work too. The SQL is identical; only the window changes.
Part 8: The Makefile, updated
Four new targets joined the Makefile:
tables: ## create the raw landing tables and the load log
psql -v ON_ERROR_STOP=1 -f sql/02_raw_tables.sql
load: ## load the generated CSVs into the raw schema
$(PYTHON) -m ingestion.load_generated
psql: ## open psql as the project user, in the project database
psql
reset-raw: ## DEV ONLY: empty the raw tables and the load log
psql -v ON_ERROR_STOP=1 -c "TRUNCATE raw.customers, raw.bank_transactions, raw.applications, raw.ingestion_runs RESTART IDENTITY"
-v ON_ERROR_STOP=1 makes psql stop at the first error instead of carrying on through a broken script. make psql works with no arguments because the Makefile loads .env and exports it, so psql finds the standard PGHOST, PGUSER and related settings automatically.
reset-raw is labelled DEV ONLY for a reason. On a real system, you never wipe the raw layer; it's the permanent record of everything that ever arrived. Locally, while practising, being able to start clean is useful. RESTART IDENTITY resets run_id numbering to 1.
Part 9: Shipping it
Same workflow as part two: a branch, small commits that each tell one part of the story, a pull request, a review of my own diff, then a squash merge.
git switch -c feat/raw-loading
git add sql/02_raw_tables.sql
git commit -m "Add raw landing tables and ingestion run log"
git add ingestion/
git commit -m "Add idempotent CSV loader with checksum, header and transaction safety"
git add tests/ingestion/
git commit -m "Add database tests for loading, idempotency, lineage and failure handling"
git add -A
git commit -m "Add make tables, load, psql and reset-raw targets"
git push -u origin feat/raw-loading
gh pr create --title "Load generated data into the raw schema" --body "..."
gh pr merge --squash --delete-branch
git switch main
git pull
Before every commit, git status confirms that data/generated/ never appears. Generated data stays out of Git because anyone can recreate it exactly with make seed.
Notes for Windows and Linux
Linux (Ubuntu, Debian, WSL): install PostgreSQL from the official instructions and the client with sudo apt install postgresql-client. Everything in this post works unchanged once psql is on your PATH.
Windows: the smoothest path is WSL with the VS Code WSL extension, then the Linux notes above. make and the .env loading in the Makefile assume a Unix shell. If you use native Windows PostgreSQL, the SQL itself is identical; run it from pgAdmin or VS Code's SQLTools.
Time zones: whatever your machine's zone, SET timezone = 'UTC'; at the start of a psql session makes the output match the files.
Traps worth knowing
| You see | What it means | Fix |
|---|---|---|
relation "raw.bank_transactions" does not exist |
Tables weren't created | make tables |
LoadError: No manifest at ... |
Nothing has been generated yet | make seed |
LoadError: ... checksum does not match |
A file changed after it was generated | Regenerate with make seed, then make load |
LoadError: ... unexpected columns |
A file's header changed | Regenerate, or update the loader's SOURCES if the change is intentional |
relation "tmp_load" already exists |
An implicit transaction is wrapping your loads | Connect with autocommit=True |
connection refused |
PostgreSQL isn't running | Start the service |
Timestamps show +03 (or your local offset) |
The session is in local time | SET timezone = 'UTC'; |
psql shows : and won't take input |
You're in the pager | Press q |
Leftover test_raw_... schemas |
A test run was killed mid-way | DROP SCHEMA test_raw_xxxxxx CASCADE; for each |
Tests report 10 skipped |
The tests can't reach the database | Start PostgreSQL and check the connection settings |
The lessons I'm keeping
- Land untrusted data as text in a temporary table, then convert on the way into the real table.
- Bulk load with
COPY, never row by row. - A primary key plus
ON CONFLICT DO NOTHINGmakes a load idempotent. - Put each file's rows and its status in one transaction: all or nothing.
- Log the attempt first, in its own committed transaction, so failures leave a record.
- Check the fingerprint and the header before loading anything. Fail loudly, with helpful messages.
- Add lineage columns so every row traces back to the load that brought it in.
- Record what arrives in raw; test relationships later rather than enforcing them at the door.
NUMERICfor money,TIMESTAMPTZfor moments.- A timestamp's display depends on the session time zone; its meaning doesn't.
- Use placeholders for values. Never paste values into SQL text.
- Index the columns you filter and sort by most, then confirm it with
EXPLAIN ANALYZE. - Know your database driver's transaction defaults. Use autocommit with explicit transactions.
- Database tests need isolation and a graceful skip.
- Never open source CSVs in Excel and save them.
- When tests catch what manual runs missed, believe the tests, then add a guard so it can't come back.
A checklist for loading any new source
At work or on the next project, this is the sequence I'll follow, whether the source is a file, an API or another database:
- Decide the primary key. If the source doesn't have a stable one, that's the first problem to solve.
- Write the landing table: real types,
NOT NULLwhere it's genuinely required, lineage columns, no foreign keys. - Create a load log table before writing the loader.
- Validate before loading: fingerprint or version, expected columns, row count.
- Land raw data into a temporary table, then insert with conversion and
ON CONFLICT. - Wrap each batch's data and status in one transaction.
- Log failures with the real error message.
- Prove idempotency by loading twice and comparing counts.
- Break it on purpose: bad file, changed shape, bad value. Confirm each is refused and logged.
- Test against an isolated schema, and skip cleanly when the database isn't available.
What's next
The data is in the database, but it's still raw. The next stage builds the first transformations with dbt: a staging layer that cleans and standardises, then the first real feature, income regularity, built on the LAG pattern from query 5 and the available_at <= decided_at rule from query 8. For the first time, one customer's transactions will flow all the way from a file to a feature served over an API.