Errors, SQLSTATE and the Status Byte
Tell a client what went wrong in a code it understands, and after every answer, what state its session is in
Send each error with PostgreSQL's SQLSTATE for its droid code and the droid code in its own field; end every answer with the status byte I, T or E; make a transaction in which a statement failed refuse everything but COMMIT and ROLLBACK, both of which roll it back; and run several statements of one Query in turn, inside an implicit transaction when the client opened none.
This part needs a network, which the platform's test sandbox does not have, so its tests run on your computer. Download the test kit (it needs only Python 3) and your project, build your program, and run:
python3 run.py --stage pg-errors-txn-status --bin ./c-dbSign in to download the code you wrote here.
- SQLSTATE: five characters every PostgreSQL driver knows, grouped by class (23 is a constraint, 42 a mistake in the statement, 40 a transaction that must be retried)
- One mapping, droid code to SQLSTATE, kept in one table
- The status byte: I idle, T in a transaction, E in a failed one; a driver never has to ask
- A failed transaction: after an error, nothing but ROLLBACK makes sense, so the protocol refuses the rest
- Implicit transactions: several statements sent together succeed or fail together
- The protocol layer can add rules without changing the engine
Why this stage exists
Your server already sends an ErrorResponse when a statement fails. Its SQLSTATE so far was any five characters. A program has no way to read meaning out of an English message, so it switches on the SQLSTATE instead. A web shop retries an order on 40P01 (deadlock), shows "that email is taken" on 23505 (duplicate key), and logs a bug on 42601 (syntax error). Every PostgreSQL driver exposes this code, and code written against PostgreSQL relies on it. This stage gives every droid error the SQLSTATE PostgreSQL would give the same condition.
It also completes ReadyForQuery. Its last byte has said I so far, because no test opened a transaction. It must say where the session stands:
A driver reads this byte after every answer, so it always knows whether a transaction is open, without asking. Connection pools rely on it too, as the last stages of this track show.
One table, droid code to SQLSTATE
A SQLSTATE is five characters, and the first two are its class: 23 a constraint was violated, 42 the statement itself is wrong, 22 a value is wrong, 25 the transaction is in the wrong state, 40 the transaction was rolled back and can be retried. Your droid codes already sort errors by component. The contract adds one column: for each droid code, the SQLSTATE PostgreSQL gives the same condition. Several droid codes can share one SQLSTATE. 00701 (a duplicate primary key) and 01605 (a duplicate value in a unique index) are both 23505, because to a client both mean "that value is taken".
The droid code is not lost. It travels in the D (detail) field, so psql shows both:
$psql -h 127.0.0.1 -p 5433 -U droid droiddroid=> insert into t (id, name) values (1, 'again');ERROR: duplicate key 1 in table tDETAIL: droid error 00701
Failed transactions
In the REPL, a statement that fails inside a transaction changes nothing, and the transaction goes on: section 3 of the contract, from the very first stages. PostgreSQL is stricter. After an error, the transaction is failed, and it refuses every statement until the client ends it. The reasoning: if the third of five steps failed, the fourth was probably written assuming it worked. So a client must decide, and the only decision left is to roll back. Even COMMIT rolls back, and its tag says ROLLBACK, so a program that blindly committed learns that nothing was kept.
Drivers and connection pools are built around this rule, so the server follows it, in the protocol layer only. The engine stays exactly as it was: the failed statement changed nothing, and the transaction is still open underneath. The connection only remembers that it failed, answers 25P02 to anything but COMMIT or ROLLBACK, and rolls back when either arrives. The REPL and the line protocol keep their old behaviour, and every earlier test still passes.
Several statements in one Query
A Query can hold several statements: insert ...; select ...;. They run in turn, each sending its own result, and one ReadyForQuery comes after the last. When the client has no transaction open, PostgreSQL wraps them in an implicit transaction: they all happen, or none of them does.
The first INSERT was answered INSERT 0 1 and still did not happen. That is the point: the client sent the three as one unit. When the Query itself says BEGIN, COMMIT or ROLLBACK, the client has taken charge of the transaction, and each statement runs as if it came in a Query of its own.
The algorithm in words
Sending an error
Every error a statement raises already has a droid code. The wire turns it into fields.
- 1.Look up the SQLSTATE of the droid code in the contract's table. Keep that table in one place in your code.
- 2.Send an ErrorResponse: S and V ERROR, C the SQLSTATE, M the error line's message, D 'droid error' and the code, H the hint's text when the error has one. Then the zero byte.
Tracking the session's state
The connection keeps one more piece of state: whether its transaction has failed.
- 1.Before ReadyForQuery, look at the session: no transaction open is I, one open is T, and one marked failed is E.
- 2.When a statement fails and the session has a transaction open, mark it failed. A deadlock victim is marked failed too, though the engine has already rolled its transaction back.
- 3.While the transaction is failed, a statement other than COMMIT or ROLLBACK does not run: it gets 01903, 25P02.
- 4.COMMIT or ROLLBACK of a failed transaction rolls back whatever is still open, clears the mark, and answers with the tag ROLLBACK.
Several statements in one Query
Split the Query into statements at each ; outside a string, and run them in order.
- 1.If no transaction is open and none of the statements is BEGIN, COMMIT or ROLLBACK, begin an implicit transaction first.
- 2.Run each statement and send its result. At the first error, send the ErrorResponse and run nothing after it.
- 3.Then end the implicit transaction: commit it if every statement succeeded, roll it back if one failed.
- 4.Send one ReadyForQuery, after everything.
Output contract for this stage
These are the exact fields and bytes the tests check. The full definition, with the SQLSTATE of every code, is on the output contract page, section 96.
| Situation | ErrorResponse | ReadyForQuery |
|---|---|---|
| a statement fails, no transaction open | S/V ERROR, C from the table, M, D droid error <code> | I |
| a statement fails inside a transaction | the same | E |
| any statement but COMMIT or ROLLBACK, transaction failed | C 25P02 (droid 01903) | E |
| COMMIT or ROLLBACK, transaction failed | none; tag ROLLBACK | I |
| deadlock victim | C 40P01 (droid 01804) | E |
| Condition | droid code | SQLSTATE |
|---|---|---|
| unknown table | 00401 | 42P01 |
| a statement that does not start with a keyword, or any other syntax error | 00301, 00302 | 42601 |
| duplicate key | 00701 | 23505 |
| NULL for a NOT NULL column | 01503 | 23502 |
| no parent row for a foreign key | 01704 | 23503 |
What you should see when done
$psql -h 127.0.0.1 -p 5433 -U droid droiddroid=> begin;BEGINdroid=*> insert into t (id, name) values (2, 'n2');INSERT 0 1droid=*> select * from missing;ERROR: table missing does not existDETAIL: droid error 00401droid=!> select id from t;ERROR: current transaction is aborted, commands ignored until end of transaction blockDETAIL: droid error 01903droid=!> commit;ROLLBACKdroid=>
psql reads the status byte and shows it in its prompt: =*> inside a transaction, =!> inside a failed one. It reads it from your ReadyForQuery.
Common bugs in this stage
- Forgetting to clear the failed mark. After ROLLBACK, the next transaction starts clean.
- Answering COMMIT with
COMMITafter a failure. The client then believes its data was kept. - Sending ReadyForQuery after each statement of a multi-statement Query. One, after the last.
- Running the statements after an error. The first error ends the Query.
- Committing the implicit transaction after an error. It must roll back. That is the only reason it exists.
- Changing the engine to fail transactions. The REPL's earlier tests would break. The mark lives in the connection.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: a sorted array of
{code, sqlstate}pairs and a binary search, or aswitchon the code. Abool failedin the connection struct. - C++: a
static const std::unordered_map<std::string_view, std::string_view>. - Rust: a
matchon the code returning&'static stris checked for missing arms only if codes are an enum, which is a good reason to make them one. - Zig: a
std.StaticStringMapbuilt at compile time from the table.
Checklist
One line per thing the tests check. The tags name the test cases.
- An ErrorResponse carries ERROR, the SQLSTATE, the message and D 'droid error <code>'error-fields
- Unknown table, syntax error, duplicate key, NULL for NOT NULL and a missing parent row carry 42P01, 42601, 23505, 23502 and 23503sqlstate-mapping
- ReadyForQuery says T inside a transaction, E after an error in it, and the next statement gets 25P02; ROLLBACK gives Istatus-byte-walk
- COMMIT of a failed transaction rolls it back and answers ROLLBACKcommit-in-failed-transaction
- After a failed transaction ends, the next one starts clean and commitscommit-keeps-a-good-transaction
- Several statements in one Query answer in turn, with one ReadyForQuerymulti-statement
- An error stops the Query and rolls back what the statements before it didmulti-statement-error
- BEGIN and COMMIT inside one Query run as statements of their ownexplicit-transaction-in-one-query
- One connection's failed transaction never fails another's statementssessions-have-their-own-status
- A deadlock victim gets 40P01 and a failed transaction; the other connection goes ondeadlock-over-the-wire