System Design: A Credit Bureau
Most system design writing circles the same few problems. Shorten a URL. Serve a video. Build a news feed. They are good problems for teaching consistent hashing and CDNs, but they share an assumption that hides the hardest part of real data systems: they all assume you know who the user is.
A credit bureau does not get that. Nobody who sends data to a bureau agrees on an identifier for the person the data is about. There is no user_id in the payload. The whole system is built around reconstructing that answer, millions of times a day, and being right often enough that people can get mortgages.
I spent about two years working on Equifax’s data fabric, the platform that does this. Everything below is drawn from public sources, which turn out to be unusually detailed for this industry, plus the general shape of the problem. Links are at the bottom.
The problem
Design a system that:
- Accepts account data from tens of thousands of lenders on a monthly cycle
- Attaches every account to the right consumer, without being given a consumer id
- Keeps a complete history it can replay as of any past date
- Returns a credit report and a score in under 100 milliseconds
- Never loses the ability to prove what it said and when it said it
Point 5 is the one that changes every other decision. Hold onto it.
Who actually does this
There are four consumer bureaus in the US, and the differences between them are informative before we design anything.
| Bureau | Scale | Platform notes |
|---|---|---|
| Equifax | 250B+ records keyed and linked, 100+ source systems consolidated, 24 markets | Rebuilt on Google Cloud, journals on Bigtable, 57 data centers closed |
| Experian | 1.5B consumers, 245M credit-active in the US, 1.3B record updates monthly | Ascend built on AWS, 15 years of full-file US credit data, ~14M reports a day |
| TransUnion | 1B+ consumers across 30+ countries | OneTru on AWS, layered identity / analytics / delivery, identity IP from the Neustar acquisition |
| Innovis | Smaller, alternative-data focused | Rent, utility, telecom, no score product |
Two things stand out. First, all three of the big ones spent the last several years doing the same migration off mainframes onto public cloud, and all three describe the result in the same vocabulary: a unified data layer, an identity layer, and a governed delivery layer. When three competitors independently arrive at the same architecture, that architecture is telling you something about the problem.
Second, Innovis is the useful counterexample. It carries data the others often miss, and it does not sell a score. That separation matters: the report and the score are different products, and conflating them is the most common mistake people make when reasoning about this system.
Requirements
Functional
- Ingest periodic bulk submissions from data furnishers
- Normalize wildly inconsistent input into one internal representation
- Resolve each incoming record to a consumer identity, creating one if needed
- Append to an immutable history rather than updating in place
- Serve a report filtered by what the requester is legally allowed to see
- Compute a score at request time
- Accept disputes, investigate, and correct without destroying prior state
Non-functional
- Read latency: under 100ms end to end for a file assembly, at high QPS
- Write throughput: billions of record updates per month, arriving unevenly
- Durability: total. A lost record is a person’s history with a hole in it
- Auditability: you must be able to reconstruct exactly what the report said on any given past date
- Availability across regions, since lending decisions do not pause
That auditability line is not a nice-to-have bolted on by compliance. It is a load-bearing requirement, and it is why this system cannot be a CRUD app over a normalized relational schema.
Back of the envelope
Take the US alone. Roughly 245 million credit-active adults. A typical consumer has somewhere between 5 and 15 open and closed accounts on file. Every furnisher reports on a monthly cycle.
That gives you on the order of 2 billion account-level observations per month landing in the system. Equifax puts one of its largest journals, the US credit journal, at about 3 billion credit observations. Experian describes 1.3 billion record updates a month. The numbers line up.
Now the read side. Experian reports around 14 million credit reports a day. That is only about 160 requests per second on average, which sounds trivially small until you remember it is spiky, each request fans out across a person’s whole history, and the budget is 100ms.
So: write-heavy in volume, read-heavy in urgency. Billions of writes arriving in predictable monthly waves, against reads that are comparatively rare but must be fast and can never be stale in the ways that matter legally. That asymmetry drives the split between the ingest path and the serving path.
The input: Metro 2
Before the interesting part, the boring part that makes it possible.
Lenders do not send you JSON. They send Metro 2, a fixed-width format standardized by the Consumer Data Industry Association, which all four bureaus sit on the task force for. A Metro 2 file is 426-byte records: one header, then one base segment per consumer account, plus optional appended segments (J1 and J2 for associated consumers like a joint account holder or authorized user, K segments for extra account detail). Over 100 fields.
This is worth dwelling on because it is the rare case where an industry solved the schema problem up front. Without it, every furnisher integration would be bespoke and the bureau would be a data-cleaning company that occasionally sells reports.
Even so, the format only constrains the shape, not the content. Furnishers still send names spelled three ways, addresses in whatever format their core banking system emits, and identifiers that are present, absent, or wrong. The real work starts after parsing succeeds.
The hard part: there is no primary key
Here is the actual design problem, stated plainly.
A record arrives that says: VIKAS KUMAR YADAV, 12 MG Road, Pune, DOB 1991-04-xx, account ending 4471, balance 82,000.
Which person is that?
You do not have a key. You have a bag of weak signals, each of which is individually unreliable:
- Name. Spelled inconsistently, transliterated, ordered differently, abbreviated, married-name changes
- Address. People move. Two people share one. The same address is written six ways
- Date of birth. Strong when present and correct, frequently one of those two
- Government ID. Strongest single signal, often absent, occasionally shared or mistyped
- Phone, email. Shared within households, recycled by carriers
The naive approach is deterministic matching: exact equality on some combination of fields. It is fast, explainable, and it breaks the moment one character is off. Real systems use it as a first pass because when it hits, it is cheap and certain.
The rest goes to probabilistic matching. The classical framework here is Fellegi-Sunter: for each field you compare, estimate the probability that the field agrees given the records are the same person (the m-probability) and given they are not (the u-probability). The log ratio gives you a weight per field, you sum the weights across fields into a match score, and you compare that score against thresholds.
The key insight from that model is that field agreement is worth different amounts depending on how rare the value is. Two records agreeing on a common surname is weak evidence. Two records agreeing on a rare one is strong. Two records agreeing on a full government ID is nearly conclusive. Treating all field matches equally is the single biggest thing that separates a naive matcher from a working one.
You then have three outcomes, not two: match, no match, and a middle band that goes to human review or stays unresolved.
The two ways to be wrong
This is where credit bureaus differ from, say, a marketing CDP doing the same technical task.
False positive: you merge two people. Someone else’s defaults are now on my file. I get denied a loan for a debt that was never mine, and I have to fight the system to prove a negative.
False negative: you split one person into two. My history is fragmented, my file looks thin, and I get worse terms than my actual record deserves. If it is bad enough I am credit invisible.
Both are harmful, and they are not symmetric in how they get discovered. The false positive generates an angry consumer and a dispute. The false negative is silent. Nobody files a complaint that says “my file should contain more bad news about me.”
So the threshold is not a tuning parameter you optimize for F1 and forget. It is a policy decision with a regulator attached to it, and it needs to be versioned, explainable, and testable against a labeled set that you maintain forever.
Merges and splits
Identity is not decided once. You learn things later.
Two keys turn out to be the same person, and you have to merge: pick a surviving key, repoint the history, keep the mapping so the old key still resolves. One key turns out to be two people, and you have to split: decide which observations go where, which is much harder because the evidence that separated them may be thin.
Both operations rewrite the meaning of history that has already been served to customers. Which brings us to the part of the system that makes this survivable.
Architecture
The public descriptions of Equifax’s fabric name the stages explicitly: ingestion, keying and linking, journaling, and purposing, with a curation and conversion step at the front.
furnishers consumers of data
| ^
v |
+---------+ +-----------+ +---------+ +--------+ |
| prep |-->| ingestion |-->| keying |-->|journal |--+
| curate | | dedupe | | & | | append | |
| convert | |idempotent | | linking | | only | |
+---------+ +-----------+ +---------+ +--------+ |
| |
v |
+----------+|
|purposing ||
| policy |+
| filter |
+----------+
Stage 1: Prep and conversion
Parse, validate, normalize. Names get standardized, addresses get run through postal normalization, dates get a single representation.
The important design rule here: never reject a whole file for a few bad rows. A furnisher’s monthly submission is millions of records and they are not going to redeliver it quickly. Bad rows get quarantined with a reason code and the rest proceeds. The quarantine has to be a real queue that someone works, not a dead-letter bucket nobody reads.
Stage 2: Ingestion
Land the cleaned records. The hard requirement is idempotency, because resubmission is normal, not exceptional. A furnisher discovers an error and sends a corrected file for a period you already processed.
You need a deterministic identity for each record derived from its content plus its reporting period, so reprocessing converges instead of double-counting. Getting this wrong produces duplicate tradelines, which look to a scoring model like the consumer suddenly opened twice as many accounts.
Stage 3: Keying and linking
Everything from the identity section lands here. The output is an association between the incoming record and a persistent internal consumer key.
Design note worth stating: the link is its own entity, not a foreign key on the record. You store the fact that record R was linked to key K, when, by which matcher version, and with what confidence. That is what makes merges, splits, and after-the-fact investigation possible. If you overwrite a consumer_id column, you have destroyed the evidence for a decision you may have to defend years later.
Stage 4: Journaling
This is the heart of it, and it is the stage most system design posts would never think to include.
The journal is an append-only history of observations. Not current state. Observations. Equifax describes journals as “the detailed history of observations across data domains” and hosts them on Bigtable, with the US credit journal holding about 3 billion of them.
The reason is bitemporality. There are two independent time axes:
- Valid time: when the fact was true in the world. The account went 30 days delinquent in March
- Transaction time: when the system learned it. The furnisher reported it in April, corrected it in June
A regulator, a court, or a dispute can ask what your report said on a specific day two years ago. You cannot answer that from current state at any price. You can only answer it by never throwing anything away and reconstructing the view as of that pair of timestamps.
This is also why a correction is an append, not an update. The old observation stays, superseded and marked as such. “Delete the wrong record” is exactly what you must not do, because the wrong record is evidence of what you told people while it was there.
Storage follows from the access pattern. You read all observations for one consumer key, ordered by time, and you write constantly. That is a wide-column store with the consumer key in the row key and time in the column dimension, which is precisely what Bigtable is for. A normalized relational model would make you join across billions of rows to assemble one person.
Stage 5: Purposing
The same underlying journal produces different output for different requesters, and the difference is legal, not technical.
Under the FCRA, a requester needs a permissible purpose to see a file at all. Beyond that, what they can see varies: some data is suppressed by consumer request, some ages out, some is restricted by the purpose of the pull. Negative information generally comes off after seven years. Bankruptcies can be reported for up to ten. A soft inquiry is visible to the consumer but does not affect the score the way a hard inquiry does.
So purposing is a policy engine over the journal. Given a consumer key, a requester, and a declared purpose, it decides which observations are visible, applies the aging rules, and assembles the file. TransUnion describes its delivery layer in nearly identical terms: unified governance and permission-based access controls, with auditability.
Two rules that matter:
- Aging is applied at read time, not by deleting data. The observation stays in the journal past seven years. It just stops being visible in the report. Deleting would break auditability and make “what did the report say in 2021” unanswerable
- Every access is itself an event. Who pulled it, when, under what purpose. That log is a compliance artifact and, for hard inquiries, is data that goes back into the consumer’s own file
The read path
Assembling a file: resolve identity from the inbound query (which is itself a matching problem, with only the query’s name and address to go on), fetch the journal slice for that key, apply purposing, package, return. Equifax describes “finding, farming, building, packaging and returning that file in under 100 milliseconds.”
Caching is where instinct misleads you. The obvious move is to cache assembled reports. You mostly cannot. The data must be current as of the pull, the purposing depends on the requester, and a cached report served after a dispute resolution is a compliance problem rather than a stale-cache annoyance.
What you can cache is further down: the identity resolution for a repeated query, the journal slice for a key, precomputed attributes. The assembly stays fresh, the expensive inputs to it do not have to be.
The score is not stored
This is the piece most people get wrong, and it falls out of the design cleanly.
A credit score is not a field in your record. It is computed at request time, from the file as it exists at that instant, by a model that is versioned separately from the data. The bureau holds the data; FICO and VantageScore build models that run against it; the bureaus also build their own.
FICO weights payment history around 35% and amounts owed around 30%, with length of history, new credit, and credit mix making up the rest, and it needs about six months of history to score you at all. VantageScore 4.0 weights payment history around 41%, with depth of credit and utilization around 20% each, uses trended data over a 24-month window rather than a point-in-time snapshot, and can score a file with as little as one month of history.
Two consequences for the system design:
- The scoring layer needs the same as-of machinery as the report. Reproducing a score from 18 months ago means reconstructing both the file as it stood and the model version that ran on it. Model version is part of the audit record
- Trended models change the storage requirement. A model that looks at 24 months of balance trajectory needs the journal, not a current-state snapshot. The append-only design you adopted for compliance turns out to be what makes modern scoring possible
And now the classic question answers itself: why are my three scores different? Furnishers are not required to report to all four bureaus and many report to some subset. The ones that do report on different schedules, so the same account lands at different bureaus on different days. Different files produce different scores, before you even get to different model versions. The scores differ because they are computed from different data at different instants, exactly as the architecture implies.
Disputes
A consumer says a record is wrong. Under the FCRA the bureau generally has 30 days to investigate, extended to 45 if the consumer supplies additional documentation mid-window.
The flow: accept the dispute, identify the observations in scope, route to the furnisher for verification, and then correct, delete from view, or confirm. Every step appends. The outcome has to be reportable back to the consumer and reconstructible later.
Notice this is a write path with a legal deadline attached, which is not a phrase that appears in most architectures. It cannot sit behind the monthly batch cycle. This is a large part of why the system needs a streaming path at all.
Batch and streaming
Both, and the split is not arbitrary.
Batch fits bulk furnisher submissions. They arrive on a monthly cadence in large files, throughput matters more than latency, and reprocessing a whole period must be possible.
Streaming fits everything with a clock on it: disputes, fraud alerts, security freezes, consumer-initiated corrections.
The design goal is that both paths run the same stage logic rather than two implementations that drift apart. This is exactly what Apache Beam’s unified batch and streaming model is for, and running it on a managed runner like Dataflow means the same pipeline definition executes in both modes. Two implementations of identity matching is how you end up with a consumer whose file says different things depending on which path touched it last.
Failure modes
What actually breaks, and what you monitor:
| Failure | Symptom | Detection |
|---|---|---|
| Matching threshold drifts loose | Two people merge | Rate of merges per period, dispute volume by reason code |
| Threshold drifts tight | One person splits, thin files | Rate of new key creation vs expectation, credit-invisible rate |
| Furnisher schema change | Parse failures, silent field shifts | Quarantine rate per furnisher, field-level distribution drift |
| Non-idempotent reprocessing | Duplicate tradelines | Account count per consumer distribution |
| Purposing policy bug | Wrong data disclosed | This is the one you cannot detect after the fact. It needs tests, not monitors |
That last row is the honest one. Most failures here are detectable in aggregate statistics, which is why distribution monitoring beats per-record validation. But a purposing bug is a disclosure that already happened, and no dashboard undoes it. It gets caught by tests and review or it does not get caught.
Tradeoffs
Where this design could have gone the other way:
Append-only journal vs current-state store. Journaling costs storage and makes every read an assembly. A current-state store would be simpler and faster. It is unavailable to you, because auditability is a hard requirement. Worth noting that the constraint turned out to be a gift: trended scoring models need the history anyway.
Wide-column vs relational. Bigtable-style storage gives fast single-entity reads at scale and gives up joins, ad-hoc queries, and transactions across entities. For the serving path that is the right trade. It means analytics needs a separate path, which is what Experian’s Ascend sandbox and the various warehouse layers exist to serve.
Probabilistic matching vs deterministic only. Deterministic-only would be explainable and defensible and would fragment a large fraction of files. Probabilistic buys coverage and costs you a threshold you have to defend to a regulator. Every bureau made the same call.
Compute score at read vs precompute. Precomputing would blow the latency budget wide open. It also would be wrong: the score has to reflect the file as of the pull, and the model version is not stable.
What makes this problem interesting
Strip out the domain and the transferable lesson is this: when your system has a legal obligation to explain its past behavior, immutability stops being an architectural preference and becomes the foundation. Every other decision here, the journaling, the link as a first-class entity, the read-time aging, the model versioning, follows from that single requirement.
Most designs treat auditing as logging bolted on the side. This one treats the audit trail as the primary data structure and derives the current state from it. That is the same instinct behind event sourcing, arrived at by an industry that had no choice.
Sources
- Equifax: Data Fabric and Explaining the Equifax Data Fabric
- Equifax uses Bigtable to increase the speed of innovation, Google Cloud Blog
- Equifax on Google Cloud customer story
- Experian Ascend Platform
- TransUnion: Supercharging our technology transformation with OneTru
- CDIA: Metro 2 Format
- FCRA Sec. 605, requirements relating to information contained in consumer reports
- CFPB Circular 2022-07: reasonable investigation of consumer reporting disputes
- The data-adaptive Fellegi-Sunter model for probabilistic record linkage
- Experian: the difference between VantageScore and FICO scores
A shorter version of the Equifax-specific part of this first appeared on LinkedIn.