Error Handling

Every failure PormG raises for a domain problem is a subtype of PormGError, so one catch clause covers the whole surface:

try
    M.Result.objects.filter("driverid__surname" => "Senna").update("points" => 25)
catch e
    e isa PormGError || rethrow()
    @error "PormG rejected the write" msg=error_message(e) type=typeof(e)
end

rethrow() on anything that is not a PormGError matters: your own exceptions, and Julia-level misuse such as a missing keyword argument, still propagate as themselves. Only driver failures are wrapped into the taxonomy.

This page is the task-oriented view — which operation raises what, and how to react. For the full type-by-type reference, including every umbrella and the fields each type carries, see Error taxonomy in the API reference.

Which operation raises what

Catch the umbrella when you want a category, the concrete type when you want a reaction.

Reading

CallRaisesWhen
get(...)DoesNotExistNo row matched
get(...)MultipleObjectsReturnedMore than one row matched; carries count
earliest(...) / latest(...)DoesNotExistEmpty queryset — unlike first()/last(), which return nothing
row.driverid on an unprojected ForeignKey / OneToOneFieldLazyTraversalErrorPormG never lazily loads a relation — project it with values(...)
any filter, values or order_byUnknownFieldErrorThe field name does not exist on the model (lookups are case-sensitive). Names the table searched and its available fields. A path that ends at a relation — a reverse accessor or a ManyToManyField, such as "result" or "raceid__result" — names no column: the message says which relation it is and asks for a column after it ("result__points")
any filterFilterErrorThe predicate itself is malformed — an unknown lookup, an operator misused, a list where one value belongs, an isnull that is not a Bool
any filter, with a value its field cannot takeInvalidValueErrorText in a number column, a malformed date or UUID, a date part outside its range under = or @in (a @year outside 1–9999 included), a @yyyy_mm that is not a calendar month, a @family other than 4 or 6, a JSON path compared with a value that is not a base-10 number, or with Inf / NaN. The same type a write raises (it was a FilterError until #971 and #988); the message names the field, never the value
A feature the backend lacks (Backend Capabilities)BackendCapabilityErrore.g. on SQLite: iunaccent_*, the regex / iregex lookups and their negated twins, full-text search (@search, SearchRank, SearchHeadline), JSONB containment, window frame=, with_advisory_lock(...; on_missing_lock = :error), a ToChar format outside the portable table, a Cast, or a Case/Coalesce/Greatest/Least output_field, to timestamp, time, interval or bytea
makemigrations on SQLiteBackendCapabilityErrorA DecimalField declares max_digits above 15, which SQLite cannot store exactly — see DecimalField
anything else about query shapeQueryBuildErrorThe long-tail default

Between FilterError and InvalidValueError on a filter, the rule is: a value that would be bound as a parameter is refused with InvalidValueError; an argument that shapes the SQL is refused with FilterError. So a bad value — the part of a filter an end user usually supplies — is an InvalidValueError whichever check refuses it, the field's or the lookup's own (#988).

Writing

CallRaisesWhen
create / update / delete, bulk, M2M mutatorsWritesDisabledErrorThe connection has change_data: false — see Creating Records
update / delete with no filterUnsafeMutationErrorRefused as unsafe; delete accepts allow_delete_all = true
delete with limit/offset/order_by/distinct/aggregatesUnsafeMutationErrorThose shapes make cascade counting unreliable — see Deleting Records
update on a handler with a filter on a values() aliasUnsafeMutationErrorThe filter resolves through the projection, which an UPDATE does not carry; a plain values() is ignored — see Updating Records
bulk_update on a handler with limit/offset/order_by/distinct/aggregates, a CTE, a cjoin, or a filter on a values() aliasUnsafeMutationErrorAn UPDATE cannot express them; the handler's plain filters are kept — see Bulk Operations
delete of a row referenced with on_delete = PROTECTProtectedErrorThe data forbids it; reassign or delete the referencing rows first
create / update naming a field that does not existUnknownFieldErrorSame message as the read path above — names the table searched and its available fields. Reverse accessors are not listed: they are addressable in a filter path, but they are not columns you can write
bulk writers, get_or_create / update_or_create naming a field that does not existUnknownFieldErrorSame type, with their own wording and no available-field list — usually the model and the field, and for a bulk_update columns= mapping the DataFrame's columns instead
create with a null value on a non-null fieldInvalidValueErrorRejected by PormG before any statement is sent
create / update / the bulk writers with a value its field cannot takeInvalidValueErrorRejected before any statement is sent. The message names the operation, the field and, for a bulk write, the row — never the value, which could be a secret. The same facts are fields on the error: e.kind (:type, :format, :range, :nul, :json_nul, :other), e.field, e.row
a text value containing a NUL character ('\0'): in a filter, a write (create, update, the bulk writers), a raw fetch value or an advisory-lock keyInvalidValueErrorRejected before any statement is sent, the same on every backend: PostgreSQL text cannot store a NUL, and the drivers would truncate the value or fail. A write names the field, a bulk write the row too; binary values are not affected. A JSONField value is the next row
a JSONField value containing a NUL character (JSON writes it as \u0000): in a write, a @jcontains filter or a get_or_create lookupInvalidValueErrorRejected before any statement is sent, the same on every backend: PostgreSQL jsonb cannot store \u0000, and SQLite and MySQL are refused too so the engines agree. A write names the field, a bulk write the row too. The six-character text \u0000 — a backslash followed by u0000 — is not a NUL and is stored as written
create / bulk_insert violating a constraintIntegrityErrorThe database refused it — UNIQUE, FOREIGN KEY, NOT NULL, CHECK

Transactions and connections

CallRaisesWhen
atomic(durable = true) inside an open transaction on the same databaseTransactionErrorIt must be the outermost transaction
an ORM call on a database with no open transaction — by the model's binding or routed with .db() — while a transaction is open on anotherTransactionErrorWrap the call in atomic on the database it runs on, or move it outside the transaction
without_foreign_keys inside an open transaction on the same databaseTransactionErrorIt must be the outermost transaction, on PostgreSQL and SQLite; MySQL refuses without_foreign_keys with BackendCapabilityError wherever it is called
any query inside a transaction, connection lost mid-flightOperationalErrorTransient. Retry the whole transaction, never the statement
any query outside a transaction, connection lost mid-flightOperationalErrorTransient, and the pool has already recovered. A read (list, first, get, count, exists, …) you know is safe to repeat can run again — wrap it in with_read_retry; a write only when a second run changes nothing, because it may already have committed — see Dropped connections
with_read_retry inside an open transactionTransactionErrorRefused before the block runs. A lost connection loses the whole transaction, so retry the atomic block, not one read in it. Library code that cannot know whether it runs inside one passes inside_transaction = :run_once, which runs the block once there instead
any query, pool saturatedPoolTimeoutErrorRaise pool_size/pool_timeout — see Advanced Configuration
any query, database unreachablePoolConnectErrorCarries the cause and a redacted connection string
any query, PostgreSQL connection string libpq cannot parsePoolConnectErrorFails fast; cause is an InvalidConfigurationError with the quoted fragment masked — see Advanced Configuration
with_advisory_lock(...; wait = false)OperationalErrorLock held elsewhere. Never raised on SQLite — see Advisory Locks
with_advisory_lock(...; on_missing_lock = :error) on SQLiteBackendCapabilityErrorSQLite has no advisory locks; the body would run unprotected, so it is refused instead
get_or_create on PostgreSQL, the insert returns no row and the row cannot be read backOperationalErrorUsually a race: another session deleted the conflicting row, and the call retried can succeed. A trigger or rule that skips the insert gives the same error on every retry (#1299)
get_or_create on SQLite, the insert reports success but the row cannot be read backStatementErrorNo race is possible under the write lock: a trigger dropped the row, or a PormG fault. A retry fails the same way (#1299)
get_or_create / update_or_create on MySQL, the insert succeeds but the row cannot be read backStatementErrorA trigger dropped the row, or a PormG fault. A duplicate-key error that re-reads nothing raises the IntegrityError instead (#1299, #1267)

Configuration and migrations

CallRaisesWhen
Configuration.load(...)PormG.Configuration.MissingConfigurationErrorNo connection.yml found; try PormG.setup(path)
Configuration.load(...)InvalidConfigurationErrorUnknown or missing adapter, unsupported extension, bad extensions shape, an environment block that is not a block of settings, an absolute path together with root
upgrade_guide(...)InvalidConfigurationErrorThe upgrading/ log bundled with the install is missing or holds no entries — a broken install, reported so it cannot read as "nothing to port"
model definitionFieldValidationError / ModelDefinitionErrorBad field argument / bad model shape. Catch DefinitionError for both
makemigrations / migrate / dry_runInvalidMigrationErrorThe migration or the schema it describes is not valid — a plan file that does not parse, say, or a label that reads like a data step (Data (Pre):, data (post):) but is neither Data (pre): nor Data (post):, or a # pormg-lossy-alter: line marked handled=pre that is misspelt, sits on a change that cannot take it, or is in a plan with no Data (pre): step. No pending plan is not one: migrate returns outcome = :nothing_pending
makemigrations over a pending plan holding data stepsInvalidMigrationErrorThe plan has hand-written Data (pre):/Data (post): steps, which makemigrations cannot regenerate, so it neither overwrites nor discards it. Apply it with migrate first, or move the steps out
run_once inside an open transactionTransactionErrorA data step commits on its own, with its record. Call it outside the transaction block
makemigrations / migrate / status / dry_run / discard_pending_migration / import_models_from_*InvalidConfigurationErrorThe key is a register_connection entry, which has no models folder — see Dynamic Multi-Tenancy
migrate on a destructive planPormG.Migrations.DestructiveMigrationErrorNon-interactive run without destructive = true; carries statements, and lossy_alters for a column change that alters existing values
migrate on a plan whose column or constraint change would fail on existing rowsPormG.Migrations.MigrationPrecheckErrorNon-interactive run; nothing was written. destructive = true does not bypass it. Carries findings; a change marked handled=pre is never among them
migrate on a database that no longer holds the schema the plan was generated againstPormG.Migrations.PlanPreconditionErrorNo statement of the plan ran and no failed row was recorded. Regenerate the plan against this database, or remove its # pormg-schema-table: lines. Carries tables
Four types need a qualified name

DestructiveMigrationError, MigrationPrecheckError, PlanPreconditionError and MissingConfigurationError are not on the using PormG surface — reach them as PormG.Migrations.DestructiveMigrationError, PormG.Migrations.MigrationPrecheckError, PormG.Migrations.PlanPreconditionError and PormG.Configuration.MissingConfigurationError. Catching their umbrellas (MigrationError, ConfigurationError) works unqualified.

Reading a caught error

Use error_message(e), not e.msg. Most types carry a msg field, but eight do not — they carry structured fields instead, and e.msg on those is a FieldError:

TypeFields instead of msg
DoesNotExistmodel_name, filters
MultipleObjectsReturnedmodel_name, count, filters
PoolTimeoutErroradapter, pool_size, max_size, attempts, elapsed_seconds
PoolConnectErroradapter, cause, connection, attempts, elapsed_seconds
IntegrityError, OperationalError, StatementErroradapter, cause, and the reason as data: sqlstate, constraint, table, column, message
DestructiveMigrationErrormsg, statements, lossy_alters
MigrationPrecheckErrormsg, findings
PlanPreconditionErrormsg, tables

error_message renders any of them to a plain String, so it is always safe:

catch e
    e isa PormGError || rethrow()
    @error "failed" msg=error_message(e)
end

A database error is safe to show

IntegrityError, OperationalError and StatementError carry the reason the database gave as data, and their text is built from those fields alone: error_message(e), showerror and string(e) never contain the database's DETAIL, HINT or LINE n: excerpt, which quote the row. So error_message(e) is safe to return to a client (#987).

FieldWhatLibPQPostgres.jlSQLiteMySQL
sqlstateThe SQLSTATE, e.g. "23505" for a unique violation✓✓—✓
constraintThe constraint that refused the row✓✓—a duplicate key's name
table, columnThe table and column the server named✓✓—a duplicate key's table; no column
messageThe server's primary message✓✓✓only for errors whose text names objects, never a value (not a duplicate key)

A field the driver does not report is nothing, and so is one the server did not send for that error: a unique violation names its constraint and table, a NOT NULL violation its table and column, a syntax error none of the three. The same holds for a bulk_copy that violates a constraint.

message is nothing for SQLSTATE class 22, a data exception: PostgreSQL builds that message from the input (invalid input syntax for type uuid: "<value>"), so the error states only the SQLSTATE. A value PormG binds as a parameter never appears. Text your app wrote itself still can — the message of a trigger's RAISE EXCEPTION (SQLSTATE P0001), or a literal typed into raw SQL that a syntax error quotes back.

Branch on the fields, not on the message:

catch e
    e isa IntegrityError && e.sqlstate == "23505" && return conflict(error_message(e))
    rethrow()
end

On MySQL a duplicate key and a missing parent both carry SQLSTATE 23000, and a CHECK violation HY000; tell a duplicate key by e.constraint, which only a duplicate key fills ("PRIMARY", or the unique key's name).

The driver's own exception, with the full text the server sent, is in e.cause. Log sprint(showerror, e.cause) only to a sink you trust with the row's data.

What reaches your logs

PormG's own log lines never carry the database's DETAIL or the value it refused: a driver message can quote the value, and a value can be a password or a token. When with_transaction fails it logs the classified type, the driver's exception type, the SQLSTATE and error_message(e) — the safe text above — then raises the error to you. A failed ROLLBACK is logged with the driver's own exception; that statement binds no values.

The LibPQ driver logs separately, through its own Memento logger. Every failed statement prints the server's full message there at warn — DETAIL included, which on a unique violation names the value:

[warn | LibPQ]: UniqueViolation: ERROR:  duplicate key value violates unique constraint "drivers_code_key"
DETAIL:  Key (code)=(SEN) already exists.

PormG does not reconfigure a driver's logging. To keep that text out of your logs, raise the logger's level once at startup — the error is still raised to your code:

using LibPQ
LibPQ.Memento.setlevel!(LibPQ.LOGGER, "critical")

The SQLite driver does not log a failed statement, and Postgres.jl logs one only in its debug mode.

Catching a whole category

The abstract umbrellas exist so a handler can name a family without listing its members:

try
    PormG.Configuration.load("db_2"; env = "prod")
    PormG.Migrations.migrate()
catch e
    if e isa ConfigurationError
        @error "Fix connection.yml and retry" msg=error_message(e)
    elseif e isa MigrationError
        @error "The migration plan was rejected" msg=error_message(e)
    else
        rethrow()
    end
end

The umbrellas are UsageError (with FieldAccessError inside it), DefinitionError, ConfigurationError, MigrationError, PoolError and DatabaseError, all under PormGError.

Refusing a bad request

UsageError is the umbrella for "PormG refused the call for what it asked": FieldAccessError and its three cases, FilterError, QueryBuildError, UnsafeMutationError and InvalidValueError, not because the database failed. The remedy is to change the call, so it is the type a web handler maps to a bad request:

function results_from(min_points)
    try
        return M.Result.objects.filter("points__@gte" => min_points).values("resultid", "points").list()
    catch e
        e isa UsageError || rethrow()
        return (status = 400, error = error_message(e))
    end
end

results_from("lots")   # (status = 400, error = "Error in filter, field `points` …")

Catch the umbrella rather than the concrete type. A value refusal was a FilterError until #971 and #988 made it an InvalidValueError: a handler that named FilterError stopped matching, and one that named UsageError did not notice. PormGError is too wide for this job — it also catches a dropped connection, an exhausted pool and a wrong connection.yml, none of which is the caller's fault.

What is not inside it: ProtectedError (the data forbids a delete), BackendCapabilityError (the backend lacks the feature — a deployment problem, not a bad request), UnsupportedConnectionError (a PormG bug, or on MySQL what has no faithful SQL there) and the DatabaseError family.

Most UsageErrors are raised while the query is built, before anything is sent, but not all: a bulk write checks each row as it goes, so a bad later row is refused after earlier chunks were sent, and delete() may already have run its cascade statements. Without a transaction of yours, the call's own transaction rolls them back. Inside run_in_transaction they are yours to roll back: a block that catches the UsageError and returns normally commits them, so rethrow it, or catch it outside the block.

DatabaseError is the boundary worth understanding: it means the statement reached the database and was refused there. A value PormG rejects before sending — a null on a non-null field, an unknown column — never gets that far and raises the query-side type instead.

Coming from an older PormG

The taxonomy replaced the untyped errors and driver-native exceptions PormG used to raise, across several releases — so a catch block written against an older version may no longer match. The deliberate clean break is spelled out in Error taxonomy.

If you are upgrading an app, the change log carries the greps and the concrete before → after edits for each step — run PormG.upgrade_guide(from = v"<your pinned version>") to see only what applies to you, and read Upgrading PormG for the workflow.