> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# Error Handling

> Tell a duplicate, a missing parent or a deadlock apart with one function, on every engine.

Source: https://uql-orm.dev/querying/errors

A failed query throws the driver’s own error, and each engine names the same failure differently. A duplicate key is `23505` on PostgreSQL, `1062` on MySQL, `2627` on SQL Server, `11000` on MongoDB, and only a message on SQLite. `queryErrorKind(err)` reads whichever one the error carries and gives it one name:

```ts
import { queryErrorKind } from 'uql-orm';
import { User } from './shared/models/index.js';
import { pool } from './uql.config.js';

export async function signUp(email: string, password: string) {
  try {
    return await pool.insertOne(User, { email, password });
  } catch (err) {
    if (queryErrorKind(err) === 'uniqueViolation') {
      throw Object.assign(new Error('That email is taken', { cause: err }), {
        status: 409,
      });
    }
    throw err;
  }
}
```

It works on any driver error, whether or not a querier threw it. It only reads the error, so `instanceof` and the driver’s own `code` still work.

## Kinds

| Kind | What happened |
| - | - |
| `uniqueViolation` | A primary key or unique index already holds the value. |
| `foreignKeyViolation` | The referenced row is missing, or a row still referenced was deleted. |
| `notNullViolation` | A required column got no value. |
| `checkViolation` | A check constraint, or MongoDB schema validation, rejected the row. |
| `retryable` | A deadlock, serialization failure, lock timeout or busy database: run it again. |
| `optimisticLock` | An update carried a version the row has moved past, or the row is gone. |
| `usage` | The call itself is wrong, such as a `$lock` outside a transaction. |
| `security` | A `security` filter refused: its context is missing, or a write would leave its scope. |
| `undefined` | Anything else: a syntax error, a lost connection, an error from outside the database. |

`optimisticLock`, `usage` and `security` come from UQL itself, as a `UqlOptimisticLockError`, `UqlUsageError` or `UqlSecurityError`. Each extends `UqlError` and carries `kind` and an HTTP `status` (`409`, `400` and `403`). Check them by kind; reach for `instanceof UqlOptimisticLockError` only to read its `expected` and `actual` version.

## Retrying a transaction

By the time a `retryable` error leaves `transaction()`, the transaction has rolled back, so the whole callback can run again. Retry the transaction, never one statement inside it:

```ts
import { queryErrorKind } from 'uql-orm';
import { Item } from './shared/models/index.js';
import { pool } from './uql.config.js';

async function withRetry<T>(work: () => Promise<T>, attempts = 3): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await work();
    } catch (err) {
      if (attempt === attempts || queryErrorKind(err) !== 'retryable') {
        throw err;
      }
    }
  }
}

export function takeOne(id: number) {
  return withRetry(() =>
    pool.transaction(
      async (querier) => {
        const item = await querier.findOneById(Item, id, {
          $select: { stock: true },
        });
        await querier.updateOneById(Item, id, { stock: item!.stock! - 1 });
      },
      { isolationLevel: 'serializable' },
    ),
  );
}
```

## Over HTTP

The [HTTP handlers](https://uql-orm.dev/http.md) answer an error that has a numeric `status`, such as UQL’s own or one a hook throws, with that status and its message. A `uniqueViolation` or `foreignKeyViolation` becomes `409 Conflict`, and a `notNullViolation` or `checkViolation` `400 Bad Request`. Those carry only the generic message, because the driver’s names your tables and PostgreSQL’s echoes the value. Anything else is a `500` with the error’s message.

## Per engine

`queryErrorKind` reads the SQLSTATE on the Postgres family (`code`, or `errno` on Bun SQL), `errno` on MySQL and MariaDB, `number` on SQL Server, `code` and `errorLabels` on MongoDB, and the message on the SQLite family:

| Engine | unique | foreign key | not null | check | retryable |
| - | - | - | - | - | - |
| PostgreSQL, CockroachDB, Neon, PGlite, Bun SQL | `23505` | `23503` | `23502` | `23514` | `40P01`, `40001`, `55P03` |
| MySQL, MariaDB | `1062` | `1451`, `1452` | `1048`, `1364` | `3819`, `4025` | `1213`, `1205`, `3572` |
| SQL Server | `2627`, `2601` | `547` naming a FOREIGN KEY | `515` | `547` naming a CHECK | `1205`, `1222`, `3960` |
| MongoDB | `11000` | | | `121` | `112`, `TransientTransactionError` |
| SQLite, LibSQL, Turso, Cloudflare D1 | `UNIQUE constraint failed` | `FOREIGN KEY constraint failed` | `NOT NULL constraint failed` | `CHECK constraint failed` | `database is locked` |

## Next steps

- [Transactions](https://uql-orm.dev/querying/transactions.md): isolation levels and who owns the commit.
- [Optimistic Locking](https://uql-orm.dev/entities/optimistic-locking.md): where `optimisticLock` comes from.
- [HTTP](https://uql-orm.dev/http.md): the error envelope the handlers return.
