A typed, foundational data-access layer for the CFPB Consumer Complaint
Database (~18.3M rows, ~5.5 GB CSV) built with the latest Drizzle ORM
(0.45.x) on SQLite via better-sqlite3.
This is intended to be used with arbitrary datasets.
The choice of ORM and the code structure are intended to ease extensibility and additional datasets by using AI or manually.
The CFPB source CSV and the built SQLite database are not committed — they are large, regenerable downloads. See Getting the data and
complaints-data/README.md.
It ships the three things you asked for:
- Tables + relations — a normalised star-ish schema (
src/db/schema.ts). - Database connection — a configured SQLite/Drizzle client (
src/db/client.ts). - Foundational repository classes — a generic base plus one per table
(
src/repositories/*), with a CSV ingestion pipeline (src/ingest/*).
- Node.js 22+ (developed on Node 24; required by
better-sqlite313.x) - On platforms without a bundled
better-sqlite3binary, Python and a C/C++ build toolchain are also required
npm install
# 0. Fetch the CFPB bulk CSV into complaints-data/ (git-ignored).
mkdir -p complaints-data
curl -L -o complaints-data/complaints.csv.zip \
https://files.consumerfinance.gov/ccdb/complaints.csv.zip
unzip -o complaints-data/complaints.csv.zip -d complaints-data
# 1. Create the SQLite schema (data/complaints.db)
npm run db:migrate
# 2. Load the CSV (streaming + chunked). Omit --limit to load all 18.3M rows.
npm run ingest -- --limit 50000
# 3. Verify the whole stack end-to-end (in-memory DB, no fixtures needed)
npm run smoke
# or: typecheck
npm run typecheckBoth the source CSV and data/complaints.db are git-ignored.
Neither the CFPB source CSV nor the built database is tracked in git. The CFPB generally updates the database daily and provides a bulk export of the full dataset:
| Source | https://www.consumerfinance.gov/data-research/consumer-complaints/ |
| Bulk endpoint | https://files.consumerfinance.gov/ccdb/complaints.csv.zip |
| Format | one header row + one row per complaint |
| Size | ~337 MB zipped, ~5.5 GB unzipped (~18M rows) |
mkdir -p complaints-data
curl -L -o complaints-data/complaints.csv.zip \
https://files.consumerfinance.gov/ccdb/complaints.csv.zip
unzip -o complaints-data/complaints.csv.zip -d complaints-data # → complaints-data/complaints.csvRe-running the download picks up newly published complaints. Ingestion is
idempotent (onConflictDoNothing on complaint_id), so reloading the same file
is safe. Use --file <path> with npm run ingest to point at a CSV elsewhere.
The flat CSV is normalised so the 18M-row fact table only stores integers and free text, while repeated labels live once in dimension tables.
products ──< sub_products ──┐
issues ──< sub_issues ──┤
├──< complaints >── companies
│
| Table | Key / unique | Notes |
|---|---|---|
companies |
id, unique name |
CSV "Company" |
products |
id, unique name |
CSV "Product" |
sub_products |
id, unique (product_id, name) |
CSV "Sub-product", FK → products |
issues |
id, unique name |
CSV "Issue" |
sub_issues |
id, unique (issue_id, name) |
CSV "Sub-issue", FK → issues |
complaints |
PK complaint_id (natural key) |
fact table, FKs → all dimensions |
complaints also stores date_received / date_sent_to_company as ISO-8601
text (YYYY-MM-DD, lexicographically sortable), timely_response as a real
boolean, zip_code as text (to preserve leading zeros), plus
company_public_response, company_response, state, tags, submitted_via.
Drizzle relations() wire up every one-to-many / many-to-one edge, and indexes
exist on all foreign keys plus the commonly filtered columns.
createDatabase() opens better-sqlite3 with pragmas tuned for a single-writer
analytical workload:
| Pragma | Value | Why |
|---|---|---|
journal_mode |
WAL |
readers don't block the long writer |
synchronous |
NORMAL |
large bulk-insert speedup, still crash-safe |
foreign_keys |
ON |
enforce relation FKs in the engine |
busy_timeout |
5000 |
wait instead of throwing SQLITE_BUSY |
cache_size |
-64000 (64MiB) |
fewer page faults while scanning |
import { createDatabase, createRepositories } from './src/index.js';
const { db, close } = createDatabase(); // or { path: ':memory:' } / DATABASE_URL
const repos = createRepositories(db);
const equifax = repos.companies.findOrCreateByName('EQUIFAX, INC.');
const recent = repos.complaints.findByCompanyId(equifax.id, { limit: 10 });
const detail = repos.complaints.getWithDetails(recent[0]!.complaintId); // eager relations
close();For the default connection there are also getDatabase() / getDb() singletons,
created lazily so importing a repository never opens a file until first use.
BaseRepository<TTable> provides the common synchronous CRUD surface
(findById, findOne, findMany, count, exists, create, createMany,
updateById, deleteById, deleteAll). Concrete repositories add
domain-specific queries:
CompanyRepository,ProductRepository,IssueRepository— unique-name lookups,findOrCreateByName()(idempotent),searchByName().SubProductRepository,SubIssueRepository— composite-key lookups,findOrCreateByName(parentId, name),findBy<Parent>Id().ComplaintRepository—buildWhere(ComplaintFilters),search(),countMatching(),findByCompanyId/ProductId/DateRange, relationalgetWithDetails(), chunked transactionalcreateManyChunked(), and aggregate helpers (countByState/Product/Company/Issue/Response/Month).
createRepositories(db) bundles them all against one connection. Methods are
synchronous because the better-sqlite3 driver is synchronous.
src/ingest/ is the ETL layer:
normalize.ts— pure CSV-record → normalised-row transforms (empty →null, Yes/No → boolean, ...), trivially unit-testable.dimension-resolver.ts— memoises label → FK in Maps and preloads from the DB, so dimensions are touched at most once per distinct label (not once per row).to-new-complaint.ts— assembles the insertableNewComplaint.src/scripts/ingest.ts— streams the CSV withcsv-parse, batches inserts inside a transaction withonConflictDoNothing(idempotent, re-runnable), logs throughput, and applies pending migrations first.
Flags: --file <path>, --limit <n>, --batch <n> (rows per INSERT, default
1000), --db <path>, --keep-indexes.
Throughput decays as the table grows, and it is continuous — the per-250k log line is just a sampling interval, not the cause. Two structural reasons:
- Random primary-key order.
complaint_idis theINTEGER PRIMARY KEY(rowid alias), but the CSV is not sorted by it — e.g. line 100k has ID 29,319,411 while line 1M has 7,214,978. Every insert therefore lands in the middle of the table's B-tree, causing page splits and random page writes across the whole file rather than an append. - Seven secondary indexes. Each row also has to be inserted into all seven
index B-trees, whose keys (
company_id,date_received,state, ...) are equally scattered.
As the file outgrows the page cache, those random writes turn into disk I/O, so the cost per row climbs.
The loader mitigates this by default:
- Deferring secondary indexes — it drops the seven
complaintsindexes before the load and rebuilds them in one sorted bulk pass afterwards (--keep-indexesopts out). Rebuilding 1M rows' worth took ~4s. - Bulk-tuned pragmas — 2 GiB page cache, an 8 GiB memory map, and
synchronous = OFFfor the duration of the load (the CSV is the source of truth and the load is re-runnable, so this is safe here — never use it for normal queries).
Measured on 1,000,000 rows on this machine:
| Setting | Throughput | Interval trend | Total |
|---|---|---|---|
--keep-indexes |
~14.6k r/s | 16.5k → 12.9k r/s (decaying) | 68.4s |
| default (deferred) | ~17.1k r/s | ~18–20k r/s (flat) | 58.4s |
The gap widens with scale, since the decaying term is exactly what grows with
row count. A further step (not yet wired up) would be to insert in complaint_id
order — an external sort of the last CSV column — to make the table B-tree
append-only as well. On a machine with 20 GB RAM the full 18.3M-row load lands
around 4.5 GB on disk.
npm run analyze opens the built database read-only and writes a
point-in-time report to reports/cfpb-complaints-analysis.md,
plus self-contained SVG charts and a monthly CSV in reports/. It runs a fixed
set of aggregate queries (volume over time, ranked companies/products/issues,
response mix, timeliness, geography, submission channels, data quality) and
mutates nothing.
Headline findings from the current snapshot (18,329,250 complaints, 2011-12-01 → 2026-10-10):
- The three national credit bureaus dominate: TransUnion, Equifax and Experian = 78.5% of all complaints (top 10 = 84.1%, HHI ≈ 2,068).
- The three credit-reporting product categories account for 82.0% of complaints; the single largest issue is "Incorrect information on your report" (45.5%).
- Volume has grown steeply — 2024 → 2025 roughly doubled (2.73M → 5.44M).
- Companies report a timely response in 99.4% of complaints; the most common resolution is Closed with explanation (61.3%).
drizzle.config.ts Drizzle Kit config (dialect: sqlite)
drizzle/ generated SQL migrations + snapshots (commit these)
src/db/schema.ts tables, indexes, relations, inferred types
src/db/client.ts SQLite + Drizzle connection & pragmas
src/repositories/ base + per-table repository classes
src/ingest/ CSV → normalised rows → DB
src/scripts/ingest.ts CLI loader
src/scripts/analyze.ts read-only analytics → reports/
src/scripts/list-companies.ts read-only company listing
src/scripts/smoke.ts end-to-end smoke test
reports/ generated analysis report + SVG charts (commit these)
| Script | What it does |
|---|---|
npm run db:generate |
Diff schema → new SQL migration in drizzle/ |
npm run db:migrate |
Apply migrations to data/complaints.db |
npm run db:push |
Push schema directly (no migration files) |
npm run db:studio |
Open Drizzle Studio |
npm run ingest |
Stream + load the CSV |
npm run analyze |
Generate the analysis report + SVG charts |
npm run companies |
List every company name (read-only) |
npm run smoke |
End-to-end assertions on an in-memory DB |
npm run typecheck |
tsc --noEmit |
The operational scripts are built to fail safe:
analyze/companiesopen the database read-only and exit with a clear message (no database file created) if it has not been built yet. They never mutate the dataset.smokeruns entirely against an in-memory SQLite database — no files are touched — so it is always safe to run.ingestis idempotent (onConflictDoNothingoncomplaint_id), so re-running it never duplicates rows. It validates--limit/--batch(a typo now errors instead of silently loading all 18M rows) and checks the CSV exists before opening the database.- Bulk-load index handling includes interruption cleanup.
ingestdrops the sevencomplaintsindexes for speed and attempts to restore them in afinallyblock and onSIGINT/SIGTERM. A hard process kill, power loss, or failed index rebuild can still leave them absent. With--keep-indexesnothing is dropped. db:pushrewrites the schema in place and, on an existing populated database, can drop columns/tables. Preferdb:migrate; treatdb:pushas a development-only shortcut against a throwaway database.
Set DATABASE_URL to override the database path (default
./data/complaints.db); both drizzle.config.ts and createDatabase() respect it.