A CUSTOMER table keyed by customer_id stores street, zip_code, city and state, and the business says a zip code determines its city and state. Is that a Third Normal Form violation, and would you actually split it out?
answer
- customer_id to zip to city: transitive, 3NF fails
- Real ZIPs cross city and state lines
- Verify with COUNT(DISTINCT city) per zip
- Reference table needs an owner and refresh
- Captured address is a snapshot, not derived
basics
~20 sFormally yes: zip_code is a non-key attribute determining city and state, a transitive dependency, so 3NF fails. Whether to split is a judgement call, because the assumed dependency often does not hold in real postal data and the reference table needs an owner and a refresh process.
solid answer
~60 sFormally it is a 3NF violation. `customer_id` determines `zip_code`, and `zip_code` determines `city` and `state`, so those two are transitively dependent on the key through a non-key attribute. The textbook fix is `ZIP(zip_code, city, state)` plus `CUSTOMER(customer_id, street, zip_code)`. In practice I would probe the premise first. The dependency is frequently false in real postal systems: US ZIP codes can straddle city and even state boundaries, many have several acceptable place names, and other countries have quite different rules. If the dependency does not actually hold, there is no violation and splitting would be modelling a fiction, forcing you to pick one city per zip and silently corrupting addresses. If it does hold for the data you accept, splitting buys real things: one authoritative row per zip, a lookup that fills city and state at entry time, and cheap validation. It also creates obligations: someone must own and refresh the reference table, and you must decide whether an address that disagrees with it is rejected or stored as given. A common middle path is to keep the reference table for validation and autofill, while storing the address as captured on the customer row for legal and delivery fidelity.
code
sql · 8 linesSELECT zip_code,
COUNT(DISTINCT city) AS cities,
COUNT(DISTINCT state) AS states,
COUNT(*) AS customers
FROM customer
GROUP BY zip_code
HAVING COUNT(DISTINCT city) > 1
OR COUNT(DISTINCT state) > 1;go deeper
Identify the chain customer_id to zip_code to city, call it a transitive dependency, and show the ZIP reference table split.
Add the anomalies removed by splitting and note the table was already in 2NF because the key is a single column.
Challenge the premise with real postal behaviour, verify with a distinct-count query, and weigh the foreign key and refresh obligations against the redundancy removed.
Set the policy: reference data for validation with a named owner and cadence, captured address stored on the record, and an explicit statement that captured values are snapshots rather than normalization defects.
## The formal analysis Relation: `CUSTOMER(customer_id, name, street, zip_code, city, state)`, key `customer_id`. Stated dependencies: `customer_id` to all attributes; `zip_code` to `city`; `zip_code` to `state`. Apply the 3NF test to `zip_code` to `city`. Is `zip_code` a superkey? No, many customers share a zip. Is `city` prime, that is, part of some candidate key? No, the only candidate key is `{customer_id}`. Both clauses fail, so the relation violates 3NF. Equivalently, the chain `customer_id` to `zip_code` to `city` is a transitive dependency. Note the table is in 2NF: the key is a single attribute, so no partial dependency is possible. This is another case where 3NF is the form that catches the problem. The textbook decomposition: - `ZIP(zip_code, city, state)`, key `zip_code` - `CUSTOMER(customer_id, name, street, zip_code)` with a foreign key to `ZIP` Lossless, because `zip_code` is the key of `ZIP`, and dependency-preserving, because both extracted dependencies are enforced by that primary key. ## Why this example is really a judgement question Interviewers use the zip code precisely because the premise is shaky. The relational analysis is easy; what they are testing is whether you validate a functional dependency before designing around it. Things that are true of real postal data: - A US ZIP code is a mail-delivery route, not a geographic region, and some cross city and even state lines. So `zip_code` to `city` and `zip_code` to `state` are not universally true. - Many ZIP codes have a preferred city name plus several acceptable alternates, so "the city" is not a single value. - ZIP codes are added, retired and re-shaped over time, so any extracted table is a slowly changing reference dataset with an upstream owner. - Internationally the rules differ. UK postcodes are far more precise; some countries have no postal code at all, so the column may be optional and the dependency undefined. If the dependency does not hold, then extracting `ZIP(zip_code, city, state)` forces exactly one city per zip and will overwrite or reject legitimate addresses. That is a data-loss bug introduced by over-applying a rule. ## Verify before deciding Check the data you actually hold: ```sql SELECT zip_code, COUNT(DISTINCT city) AS cities, COUNT(DISTINCT state) AS states FROM customer GROUP BY zip_code HAVING COUNT(DISTINCT city) > 1 OR COUNT(DISTINCT state) > 1; ``` Empty result plus a business rule confirming it means the dependency is real and the redundancy is pure duplication. Non-empty result means either the data is dirty, or the dependency is false. Distinguishing those two is the actual work, and it usually needs the domain owner, not more theory. ## What splitting buys and costs Buys: - One authoritative row per zip, so a correction is a one-row update rather than a mass update that can go half-done. - A validation surface: a foreign key rejects unknown zips, and the application can autofill city and state at entry, which reduces typos measurably. - Smaller rows and a small cached lookup table. Costs and obligations: - The reference table needs an owner, a source, and a refresh cadence. A stale postal table rejects valid new addresses, which is a worse customer-facing failure than a duplicated string. - A foreign key on `zip_code` turns "unknown zip" into a hard write failure. That may be right for a billing address and wrong for a free-text profile field. - International addresses may not fit the model at all, pushing you toward a country-scoped reference or a free-form address block. ## The pragmatic answer Most mature systems land on a hybrid, and saying so is a strong answer: 1. Keep a **reference** table of postal codes, sourced and refreshed, used for validation and autofill. 2. Store the address **as captured** on the customer, including city and state, because the address of record is a legal and delivery artifact that must not silently change when the reference data is updated. 3. Accept that (2) is a deliberate denormalization, and be explicit that the customer row's city is a *captured value*, not a derived one. Once framed that way, it is not a 3NF violation at all: the captured city genuinely depends on the customer, exactly like a historical price depends on the order line. That reframing is the punchline. Many apparent transitive dependencies dissolve when you ask what the attribute actually means: a snapshot of a fact at capture time is a different attribute from the current value of that fact, and it belongs on the row that captured it. ## What a weak answer looks like Reciting "zip determines city, therefore split" with no mention of whether the dependency is true, who owns the reference data, or what happens to addresses that disagree with it. The relational reasoning is necessary but it is the easy half.
- If a zip code can legitimately map to more than one city, what happens to the 3NF argument?The functional dependency zip_code to city simply does not hold, so there is no transitive dependency and no 3NF violation for that column. Normal forms are defined over the dependencies that are actually true; inventing one to justify a split produces a model that cannot represent real addresses.
- Why might you deliberately keep city and state on the customer row even after building a postal reference table?Because the address of record is a point-in-time capture used for delivery and legal purposes, and it must not change when reference data is refreshed or corrected. Framed as a captured snapshot it is a different attribute from the reference value, so it depends on the customer and is not a normalization defect.
- What operational risks does adding a foreign key from customer.zip_code to a postal reference table introduce?Any zip missing from the reference table becomes a hard write failure, so a stale or incomplete dataset blocks legitimate registrations. You need a refresh process with an owner, a plan for newly issued codes, and a decision about international addresses that the reference table does not cover.
saying these in an interview costs you the question
- Splitting on the assumed dependency without ever checking whether it holds in the data or the domain.
- Treating a ZIP code as a geographic region rather than a delivery route, and asserting one city per zip universally.
- Adding a foreign key to a reference table with no owner or refresh process.
- Overwriting the customer's captured address whenever the reference table changes.
- Failing to distinguish a captured snapshot attribute from a derived current value.