A nightly job suddenly fails with "more than one row returned by a subquery" — how do you fix it?
answer
- the query text did not change
- ask what changed underneath it
- run the inner query by itself first
- group the source and count per key
- silencing it is not the same as fixing it
basics
~20 sThe query did not change; the data did. A lookup assumed to be unique gained a duplicate. Isolate the subquery, find the duplicated key, then pick the intended semantics — set membership, a tighter unique predicate, or a deliberate aggregate — rather than silencing the error.
solid answer
~50 sThis error is almost always a uniqueness assumption expiring. A scalar subquery written as `= (SELECT ... WHERE code = ?)` was correct while the lookup column happened to hold one row per key, and a second row arrived. Start by running the subquery alone and grouping the source by the lookup key with `HAVING COUNT(*) > 1` to see which values duplicated and whether the duplicates are legitimate. Then choose semantics deliberately: `IN` if several matches are all acceptable, a tighter predicate on something the schema keeps unique if exactly one was meant, or an explicit aggregate such as `MAX(...)` if collapsing is genuinely correct. Avoid `LIMIT 1` without an `ORDER BY` — it converts a loud, accurate failure into a quiet, arbitrary answer. Longer term, the uniqueness the query relies on belongs in a constraint, so violations surface at write time.
code
sql · 4 lines-- fails the day a second EU office row appears
SELECT o.id, o.total
FROM orders o
WHERE o.office_id = (SELECT id FROM offices WHERE region = 'EU');go deeper
Know that this error means the subquery returned several rows where one value was expected, and that the first move is to run the subquery on its own.
Walk through diagnosis and the menu of fixes — IN, a tighter unique predicate, a deliberate aggregate, or a join — and explain what each one changes about the result.
Show incident judgment: recover the intent from the data before editing, refuse LIMIT 1 as a fix, and treat the uniqueness assumption as something that belongs in the schema rather than in query text.
Own the systemic angle — assumptions that data can violate should fail at write time, and when duplicates turn out to be legitimate, the sweep across every query relying on that uniqueness is a planned piece of work, not a series of incidents.
## Read the error as a statement about data "More than one row returned by a subquery used as an expression" is a **cardinality violation**: a subquery in a value position returned two or more rows. Nothing about it is a syntax problem, and nothing about it depends on the plan. The query has been correct-looking all along; what changed is that a lookup the author believed produced one row now produces several. That framing matters because the instinct under time pressure is to edit the query until the error stops. Editing until it stops is how a correct failure becomes an incorrect result. ## Step one: isolate and quantify Extract the subquery and run it on its own. Then ask the source table which keys duplicated: ```sql -- which lookup keys now have more than one row? SELECT region, COUNT(*) FROM offices GROUP BY region HAVING COUNT(*) > 1; ``` This answers three things at once: how widespread the duplication is, whether it is one bad row or a structural change, and whether the duplicates are meaningfully different (two live offices) or accidental (a re-import that inserted a second copy). The error message names the statement, never the offending key, so this step is not optional. ## Step two: recover the intent With the data in hand, decide what the query *meant*. There are only a few honest answers, and each has a different fix. **It meant "any of these".** The author wrote `=` out of habit where membership was intended. The predicate becomes `IN`: ```sql SELECT * FROM orders WHERE office_id IN (SELECT id FROM offices WHERE region = 'EU'); ``` This changes the result — more rows can match — so it is only right when set semantics were the intent. **It meant one specific row, and the predicate was under-specified.** The duplicates are distinguishable, and the inner query needs the distinguishing condition: a status, an effective date, a headquarters flag, a primary-key equality. ```sql SELECT * FROM orders WHERE office_id = (SELECT id FROM offices WHERE region = 'EU' AND is_headquarters = TRUE); ``` This is the best outcome, because the resulting query encodes the rule that was previously only in someone's head. **It meant "collapse them, I do not care which".** Sometimes that is genuinely true — the newest, the largest, the first. Say it with an aggregate, which returns exactly one row by construction and is deterministic: ```sql SELECT * FROM orders WHERE office_id = (SELECT MAX(id) FROM offices WHERE region = 'EU'); ``` **It meant a row per match.** If downstream really wants one output row per matching lookup row, the subquery was the wrong construct and a join is the right one. ## Step three: the fix to refuse Appending `LIMIT 1` — or `FETCH FIRST 1 ROW ONLY` — makes the error disappear immediately, which is why it is so tempting during an incident. It is the worst option available. Without an `ORDER BY`, no engine promises which row you receive; with one, ties are still broken arbitrarily. The query now returns a plausible number that may differ between runs, and the data problem that caused the incident is no longer visible to anyone. If you must ship it to unblock a deadline, ship it with the ordering that makes it deterministic and a ticket to resolve the semantics. ## Step four: move the assumption into the schema The deeper defect is that a uniqueness rule lived in query text. `= (SELECT ... WHERE email = ?)` is an assertion that emails are unique, made in a place where nothing enforces it. If the rule is real, it belongs on the table as a uniqueness constraint, so the second row is rejected at insert time by the component that created it — hours before the nightly job runs, and with a message naming the row rather than the report. If the rule is *not* real — duplicates are legitimate — then every query using `=` against that column is latent breakage, and the sweep is worth doing at once rather than one incident at a time. ## Why this failure mode is the good one It is worth saying out loud in an interview: this error is the friendly half of the scalar-subquery contract. Too many rows raises, stops the job, and points at the statement. Too few rows returns NULL, propagates silently through arithmetic and predicates, and produces a report somebody believes. A team that has learned to fix cardinality errors by suppressing them is converting the loud failure into the quiet one, which is exactly the wrong direction.
- Why is LIMIT 1 the wrong first response during an incident?It removes the symptom and keeps the defect. Without ORDER BY the row is arbitrary and may differ between runs, so the job now emits a plausible but unstable number, and the duplicated data that caused the incident is invisible. If it must ship to unblock, ship it with a deterministic ordering and an open ticket on the semantics.
- How would you keep this class of failure from recurring?Put the uniqueness where writes happen. A query using `= (SELECT ...)` asserts that the lookup key is unique; declaring that as a constraint makes the offending insert fail immediately, next to the code responsible. If duplicates are legitimate instead, every `=` against that column is latent breakage and deserves a deliberate sweep.
- How do you decide between IN and an aggregate when both stop the error?They mean different things. IN keeps every match, so the result can contain more rows than before — right when membership was the intent. An aggregate collapses to one deterministic value and keeps the row count, which is right when "the latest" or "the largest" is genuinely what the report wants. Ask what the consumer of the number expects.
saying these in an interview costs you the question
- Reaches for LIMIT 1 to silence the error
- Blames the query plan or a missing index
- Assumes the query was recently edited
- Changes = to IN without checking whether extra matches are wanted
- Fixes the query and never looks at the duplicated data