> Markdown version of https://agledger.ai/blog/pg-boss-production-lessons/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

[← Blog](https://agledger.ai/blog/)2026-05-03Engineering

# pg-boss in production: footguns we hit and how to avoid them

By Michael Cooper · Founder

Eleven operational footguns we hit running pg-boss at AGLedger. Four are API-time, seven are mostly configuration-time. All eleven are live in current releases. Reproductions, citations, and the patterns we settled on.

## The short version

We run [pg-boss](https://github.com/timgit/pg-boss) in production for about a year, across outbound delivery, webhook fanout, and scheduled maintenance. Eleven operational footguns hit us repeatedly.

Footguns 1 to 4 are API-time: you call something and it does not do what you expected. Footguns 5 to 11 are mostly configuration-time, where a mechanism looks like it is enforcing something and is not, so the queue reads as correctly configured while the constraint applies to nothing.

Footguns 1 to 4 were tested 2026-05-03 against pg-boss 12.18.2, PostgreSQL 17, Node 24 LTS. Footguns 5 to 11 were read from [`src/plans.ts`](https://github.com/timgit/pg-boss/blob/master/src/plans.ts) at each tag and confirmed against live behavior on 12.18.2, 12.26.4 and 12.27.0, PostgreSQL 18.4, Node 24.17.0. Two things change across that range and neither changes a finding: the `notify` column arrives in 12.21.0, and `insert()` drops `group.id` before 12.26.4.

Footgun 1 carries an amendment retested 2026-08-13 against pg-boss 12.27.0 on PostgreSQL 18.4: `singletonSeconds` deduplicates, but it is a wall-clock throttle rather than an in-flight lock, and `policy: 'exclusive'` is the primitive for one-at-a-time. If you are on pg-boss earlier than 11.0.8 or 10.4.0, upgrade first: issue [#535](https://github.com/timgit/pg-boss/issues/535) is fixed. The footguns below are still live in the latest releases.

API-time

1. `singletonKey` without `singletonSeconds` is a no-op on standard queues, and `singletonSeconds` is a throttle rather than a lock

2. `boss.send()` returns `null` on dedup, silently

3. `boss.schedule()` upserts on `(name, key)`; schedules silently overwrite

4. Schema drift across pg-boss majors (snake_case rename in v10, archive removal in v11)

Configuration-time

5. `groupConcurrency` exempts ungrouped rows, and shrinks your batch: 5.3x more fetches for the same 30 jobs

6. `createQueue` never updates an existing queue, so tuning reaches only databases created after the change

7. `updateQueue` has three different null outcomes, and two options cannot be changed at all

8. A queue's `policy` is immutable; changing it means delete, recreate, and your own advisory lock

9. `expired` is not a job state; the enum is six values and the order is load-bearing

10. A running boss holds the event loop open, which decides whether a fatal boot error hangs or exits 0

11. `deleteAfterSeconds` is measured from `completed_on`, so a backlog of queued jobs leaves on a separate fourteen-day clock

## 1. Why doesn't `singletonKey` deduplicate on a standard queue?

Because it only deduplicates when paired with `singletonSeconds`. On the default `standard` policy the key is treated as a label only, and parallel enqueues all create rows.

pg-boss 12.x · default queue policy · silent failure mode

The pg-boss [jobs API docs](https://github.com/timgit/pg-boss/blob/master/docs/api/jobs.md) are explicit: `singletonSeconds` is the dedup window. The error mode is silent: callers see a returned id, no exception, and ship. Reproduction:

```
import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('demo') // idempotent in v10+

  const ids = await Promise.all(
    Array.from({ length: 6 }, () =>
      boss.send('demo', { x: 1 }, { singletonKey: 'k' }),
    ),
  )
  console.log(ids.filter(Boolean).length) // -> 6, not 1
} finally {
  await boss.stop()
}
```

**A second path:** set a queue policy at `createQueue` time, so that `singletonKey` is enforced by a partial unique index instead of a time window. They do not all mean “one at a time”. Each is a partial unique index on `(name, COALESCE(singleton_key, ''))` differing only in the predicate (visible in pg-boss [`plans.ts`](https://github.com/timgit/pg-boss/blob/master/src/plans.ts)). The footgun is the default `standard` policy specifically.

| Policy | Index predicate | What it actually enforces |
| --- | --- | --- |
| short | state = 'created' | One *queued* job per key. A second send is refused while the first waits, and accepted once it starts running. |
| singleton | state = 'active' | One *running* job per key. Sends land in `created`, which the index does not cover, so parallel sends all create rows. |
| stately | state <= 'active', state in key | One job per key *per state*. A `created`, a `retry` and an `active` job can coexist. |
| exclusive | state <= 'active' | One unfinished job per key, whatever state it is in. This is the one that means “only one of these at a time.” |

`singleton` is the trap in that list: the name suggests uniqueness, the index only covers `active`, and six parallel sends produce six rows, exactly like the reproduction above.

Update, 2026-08-13: pairing with singletonSeconds is not the answer for one-at-a-time

Everything above holds: `singletonKey` alone is still a no-op on a `standard` queue. But if what you actually want is “only one of these running at a time,” adding `singletonSeconds` is the wrong fix.

`singletonSeconds` buckets on wall-clock time, not on job liveness. It sets `singleton_on` to the epoch floor of `now()/N`, and the supporting index covers every state except `cancelled`:

```
-- the throttle index
CREATE UNIQUE INDEX job_i4
  ON job (name, singleton_on, COALESCE(singleton_key, ''))
  WHERE state <> 'cancelled' AND singleton_on IS NOT NULL

-- the one-at-a-time index
CREATE UNIQUE INDEX job_i6
  ON job (name, COALESCE(singleton_key, ''))
  WHERE state <= 'active' AND policy = 'exclusive'
```

Two consequences fall out of `job_i4`. A job that has already **completed** keeps refusing new sends for the remainder of its bucket, because `completed` and `failed` are both inside the predicate. And because the key includes `singleton_on`, two sends that straddle a bucket boundary have different keys, do not conflict at all, and leave two jobs in flight on the same key. It throttles. It does not lock.

`job_i6` is the primitive for one-at-a-time. The `job_state` enum is ordered `created < retry < active < completed < cancelled < failed`, so `state <= 'active'` covers exactly created, retry and active: one job per key, and the slot frees the moment the job finishes.

| Behavior | singletonSeconds | policy: exclusive |
| --- | --- | --- |
| Second send while first is running | Refused | Refused |
| Send after the first completed | Refused for the rest of the bucket | Accepted immediately |
| Send after the first failed | Refused for the rest of the bucket | Accepted immediately |
| Two sends straddling a bucket boundary | Both accepted, two jobs in flight | Second refused |
| Two sends on different keys | Both accepted | Both accepted |

**The symptom that surfaced it.** An admin endpoint returning `409 already in progress` with zero jobs running. The scan it guarded had finished minutes earlier and was still holding its bucket.

**The wrinkle, and it is worse than it looks.** Under `exclusive`, a job in `retry` holds the slot with nothing executing. `retryDelay` is the *floor* on that hold, not its duration. It sets `start_after`, which governs when the job becomes eligible to be *fetched*. It does not move the job out of `retry`, and `retry` is inside the `state <= 'active'` window that `job_i6` covers. The job sits there until a worker actually picks it up.

With a worker polling at 2 seconds and an instant handler, the hold measured `retryDelay` plus about a second: 6.01s at a 5s delay, 16.04s at 15s. Budget roughly `retryDelay + pollingInterval/2 + handler duration`.

With nothing consuming the queue it never resolves at all. At `retryDelay` of 5s, 15s and 30s, a fresh send on the same key was still refused well past `start_after`, with the job sitting in `retry` and nothing active. So the ceiling on the hold is set by your consumer's liveness, not by any pg-boss setting: an exclusive queue whose worker has died holds every failed key's slot indefinitely. The symptom is the same 409 with nothing running that opens this section.

**What we did.** Moved the queue to `policy: 'exclusive'` and deleted the machinery the throttle had required: a lookup of the job owning the current bucket, a settled-versus-active branch, and a two-times-window heuristic that existed only to guess which job owned a bucket.

One thing to weigh before reaching for it everywhere: `exclusive` refuses the insert rather than queueing it behind the running job. That makes it the wrong choice for any work you cannot afford to drop.

Verified against pg-boss 12.18.2, 12.26.4 and 12.27.0 on PostgreSQL 18.4, Node 24.17.0, one isolated schema per version. Behavior identical on all three. Index definitions from [plans.ts](https://github.com/timgit/pg-boss/blob/master/src/plans.ts) (`createIndexJobThrottle`, `createIndexJobPolicyExclusive`).

**References:** pg-boss issues [#81](https://github.com/timgit/pg-boss/issues/81) (historical singleton context, closed 2018) and [#548](https://github.com/timgit/pg-boss/issues/548) (replace-vs-discard semantics; closed 2026-07-02).

Our first fix was a facade over `boss.send` that filled both fields, which stops the no-op but buys a throttle rather than a lock. Pick the primitive first, then wrap it.

## 2. Why does `boss.send()` return `null` instead of a job id?

A `null` return means the enqueue was deduplicated. It is documented behavior rather than an error, and nothing throws, so callers that do not check the return value drop the dedup signal.

pg-boss 12.x · documented, easy to discard

When pg-boss successfully dedups an enqueue, `boss.send()` returns `null`, not a job id, and not an error. Callers that destructure or chain on the return without checking `const id = await boss.send(...); track(id)` silently drop the dedup signal.

```
import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('demo')

  const a = await boss.send('demo', { x: 1 },
    { singletonKey: 'k', singletonSeconds: 60 })
  console.log(a) // -> a uuid string

  const b = await boss.send('demo', { x: 1 },
    { singletonKey: 'k', singletonSeconds: 60 })
  console.log(b) // -> null
} finally {
  await boss.stop()
}
```

This is documented behavior, not a bug. We landed an ESLint rule that flags discarded return values from queue-send calls after multiple callsites in our own codebase silently discarded the `null`. One of them was a metric counting enqueues. Until we caught it, the count was off by the dedup rate.

**Reference:** pg-boss issue [#548](https://github.com/timgit/pg-boss/issues/548) discusses replace-vs-discard semantics and was closed on 2026-07-02.

**What we did.** Treat every `boss.send` / `boss.insert` return value as required-to-handle. The simplest version is the lint rule. The runtime version is a wrapper that returns a discriminated union (`{ deduped: true } | { id: string }`) so the type system forces the branch.

## 3. Why did `boss.schedule()` overwrite my existing schedule?

Because `pgboss.schedule` is keyed on `(name, key)` and `key` defaults to an empty string, so a second call for the same queue name upserts over the first.

pg-boss 12.x · documented, easy to misuse

The `pgboss.schedule` table's primary key is `(name, key)`. The `key` parameter to `boss.schedule()` defaults to `''`. If you call `boss.schedule('maintenance', cron, data)` once per task, all calls share the empty default key. Only the last one survives. Every prior schedule row is upserted away.

```
import { PgBoss } from 'pg-boss'

const boss = new PgBoss({ connectionString: process.env.DATABASE_URL! })
await boss.start()
try {
  await boss.createQueue('maintenance')

  await boss.schedule('maintenance', '*/2 * * * *', { task: 'expiry-sweep' })
  await boss.schedule('maintenance', '*/5 * * * *', { task: 'audit-checkpoint' })
  await boss.schedule('maintenance', '0 * * * *',   { task: 'reputation-roll' })

  // Inspect with: SELECT name, key, cron, data FROM pgboss.schedule;
  //
  // Expected: three rows.
  // Actual:   one row, name='maintenance', key='', cron='0 * * * *',
  //           data={task:'reputation-roll'}.
} finally {
  await boss.stop()
}
```

We shipped this bug and did not notice for a while. The schedule that survived was the one we expected to fire most often, so the system looked healthy from the outside. We caught it when an unrelated recovery sweep started failing and we went looking. The schedules that ran were the ones registered last on each boot. The schedules that did not run were the ones we thought were running.

**Reference:** pg-boss schedule SQL is in [`plans.ts`](https://github.com/timgit/pg-boss/blob/master/src/plans.ts) (search for the `schedule` function); the upsert on `(name, key)` is visible in the SQL.

**What we did.** Always pass an explicit, unique `key` argument to `boss.schedule`. Wrap it: a helper that takes `(boss, queue, cron, data, taskKey)` and sets both `key: taskKey` and `singletonKey: taskKey` in one call. Never call `boss.schedule` directly.

To detect existing collisions in your own database:

```
SELECT name, key, count(*)
FROM pgboss.schedule
GROUP BY 1, 2
HAVING count(*) > 1;
```

## 4. What breaks when you upgrade across pg-boss majors?

Raw SQL against the pg-boss schema. v10 renamed columns to snake_case, v11 removed the `pgboss.archive` table, and v12 went ESM-only, so ops scripts and dashboards that hardcode the schema break silently on the matching upgrade.

pg-boss v10, v11, v12 · silent breakage in raw SQL

pg-boss has had two breaking schema changes and one breaking packaging change since v10. [v10](https://github.com/timgit/pg-boss/releases/tag/10.0.0) introduced partitioned tables, queue policies, and the snake_case column rename (`singletonkey` → `singleton_key`, etc.). [v11](https://github.com/timgit/pg-boss/releases/tag/11.0.0) removed the `pgboss.archive` table entirely and changed retention semantics; completed jobs now live in `pgboss.job` with `state = 'completed'` until `deleteAfterSeconds` elapses. v12 went ESM-only with named exports.

Anything that referenced old column names or the removed archive table in raw SQL (recovery sweeps, ops scripts, monitoring dashboards) broke silently on the matching upgrade. The v11 shape we hit:

```
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL! })

// A "find old completed jobs" query that worked on pg-boss 10.x:
const completed = await pool.query(`
  SELECT id, completed_on FROM pgboss.archive
  WHERE name = $1 AND completed_on > now() - interval '7 days'
`, ['demo'])

// On pg-boss 10.x:  returns rows.
// On pg-boss 11.x+:   throws 'relation "pgboss.archive" does not exist'
// - but only when this query actually runs, which for a weekly sweep
// can be days after the upgrade if the sweep is in a try/catch that
// logs-and-continues.

await pool.end()
```

**What we did.** A startup sentinel. On `boss.start()` completion, run one query against `information_schema.columns` for `pgboss.job` and assert every column you reference by string literal in raw SQL is present. Fail boot if a required column is missing. Throwing on boot is strictly cheaper than a silent loop.

```
const REQUIRED = ['name', 'state', 'singleton_key'] as const
const { rows } = await pool.query<{ column_name: string }>(`
  SELECT column_name FROM information_schema.columns
  WHERE table_schema = 'pgboss' AND table_name = 'job'
`)
const present = new Set(rows.map(r => r.column_name))
const missing = REQUIRED.filter(c => !present.has(c))
if (missing.length > 0) {
  throw new Error(`pgboss.job missing columns: ${missing.join(', ')}`)
}
```

The same sentinel pattern works in your migrations gate if you maintain ops dashboards.

## Fixed upstream: issue #535

pg-boss 11.0.8 (2025-10-10) · backport in 10.4.0 (2025-11-19)

pg-boss issue [#535](https://github.com/timgit/pg-boss/issues/535) (closed 2025-10-03): on a stately or singleton queue with `batch_size > 1`, when two jobs sharing a singleton key sat in `created` and `retry` states at the same time, the fetch query would grab both and try to activate them. The partial unique index on the singleton key fails the activation with a Postgres `23505 unique_violation`, and the worker stops fetching from then on. It affects `singleton` queues as well as `stately` ones.

If you are on pg-boss earlier than 11.0.8 (or earlier than 10.4.0 on the v10 line), upgrade. To confirm it is not silently affecting you, look for completed jobs sharing a singleton key with completion timestamps within milliseconds of each other:

```
-- pg-boss v11+ - completed jobs live in pgboss.job until
-- deleteAfterSeconds elapses (the archive table was removed in v11).
SELECT singleton_key, COUNT(*), MIN(completed_on), MAX(completed_on)
FROM pgboss.job
WHERE state = 'completed'
  AND singleton_key IS NOT NULL
  AND completed_on > now() - interval '7 days'
GROUP BY singleton_key
HAVING COUNT(*) > 1
   AND MAX(completed_on) - MIN(completed_on) < interval '5 seconds'
ORDER BY 2 DESC LIMIT 20;

-- pg-boss v10.x - query pgboss.archive instead (or in addition).
```

Credit goes upstream. The fix landed in 11.0.8 via the maintainer ([timgit](https://github.com/timgit)) and was backported to the v10 line in [PR #640](https://github.com/timgit/pg-boss/pull/640) by [@nrempel](https://github.com/nrempel), released as 10.4.0. We just had to upgrade.

## 5. Why did `groupConcurrency` make our throughput fall off a cliff?

Because it does not only cap concurrency, it shrinks the batch. The fetch applies `LIMIT batchSize` first and discards over-cap rows second, with no refill. Concentrating the same 30 jobs into fewer groups took the drain from 3 fetches to 16. These are counts of fetch calls rather than timings.

pg-boss 12.x · two separate traps · both silent

### Trap one: rows with no group are exempt from the cap

`groupConcurrency` constrains only jobs that carry a group. A producer that does not stamp one emits rows that are exempt, so the queue looks correctly configured from the worker side while the limit applies to nothing. The fetch predicate for `groupConcurrency: 1` is `(j.group_id IS NULL OR NOT EXISTS (... active job with the same group_id ...))`, and the `IS NULL` disjunct is unconditional. The supporting index is likewise `WHERE state = 'active' AND group_id IS NOT NULL`.

A fetch against a queue with `groupConcurrency: 1` returned 1 of 3 grouped jobs and 3 of 3 ungrouped ones. One misconfigured producer is enough to disable the cap for everything it enqueues.

**Version note.** On 12.18.2, `insert()` silently discards `group.id` even when you pass it, so every job enqueued that way is exempt and the cap constrains nothing at all. Fixed in 12.26.4 by pg-boss issue [#861](https://github.com/timgit/pg-boss/issues/861). `send()` was never affected.

### Trap two: the cap eats your batch size

`fetchNextJob` builds a `next` CTE that applies `LIMIT batchSize`, then hands the result to a `group_ranking` / `group_filtered` pair that discards rows over the cap. Nothing refills afterward. The candidate window is chosen first and thinned second, so a fetch of `batchSize: 10` against a deep per-group backlog returns far fewer than 10, and how many depends entirely on how the backlog is spread across groups.

All rows below were enqueued group-sequentially with `send()` (every job for group 1, then every job for group 2, and so on) at `batchSize: 10`, `groupConcurrency: 1`.

| Backlog shape | Enqueued | Returned | Batch fill (of 10) |
| --- | --- | --- | --- |
| 10 groups x 1 job | 10 | 10 | 100% |
| 5 groups x 2 jobs | 10 | 5 | 50% |
| 3 groups x 5 jobs | 15 | 2 | 20% |
| 2 groups x 10 jobs | 20 | 1 | 10% |
| 3 groups x 5, then 10 ungrouped | 25 | 2 | 20% |
| 3 groups x 5 interleaved with 10 ungrouped | 25 | 6 | 60% |

The last two rows are the same population and differ only in enqueue order. Ungrouped jobs are exempt from the cap, but they only help if they land inside the first `batchSize` rows by `(priority DESC, created_on, id)`. Append them after a grouped backlog and they fall outside the window, so they do not fill the batch and they wait behind it.

The drain cost follows directly. Same 30 jobs, same `batchSize: 10` and `groupConcurrency: 1`, completing each batch before the next fetch:

| Shape | Fetches to drain | Batch sizes |
| --- | --- | --- |
| 30 groups x 1 job | 3 | 10, 10, 10 |
| 10 groups x 3 jobs | 7 | 4, 5, 7, 5, 5, 3, 1 |
| 3 groups x 10 jobs | 16 | 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 2, 1, 1, 1, 1, 1 |

5.3x more fetches for the same 30 jobs, purely from concentrating the backlog into fewer groups. The cap is doing what it says; the surprise is that `batchSize` stops being a throughput knob once a group backlog forms. The effective batch is closer to “number of distinct groups with queued work, capped at `batchSize`” than to `batchSize`.

**One measurement caveat.** Batches enqueued via `insert()` are non-deterministic under `groupConcurrency`. The `job` table declares `created_on ... DEFAULT now()`, and `now()` is transaction-scoped, so every row from one `insert()` call shares a `created_on` and the ordering falls through to a `gen_random_uuid()` id. Eight identical runs returned 2 every time via `send()` and anywhere from 4 to 9 via `insert()`. If you are benchmarking this, use `send()` or you will read run-to-run variance as a version difference.

**What we did.** Every producer on a grouped queue stamps a group, enforced by a test rather than by review.

## 6. Why didn't changing a queue option in code change anything?

Because `createQueue` on a queue that already exists is a no-op for every option. Queue tuning reaches databases created after the change; anywhere the queue row already exists, it silently keeps the values it was first created with.

pg-boss 12.x · splits behavior by install age

`pgboss.create_queue()` inserts the queue row with a bare `ON CONFLICT DO NOTHING` and no update branch, so a queue row that already exists is left untouched. The function returns `void`, so nothing distinguishes the created case from the ignored one. Re-creating a queue with `retryLimit` moved from 2 to 9 and `expireInSeconds` from 60 to 999 left the `pgboss.queue` row unchanged.

It bit us on an outbound delivery queue, where `expireInSeconds` has to stay above the HTTP client timeout. Set it below, and pg-boss re-fires the job while the first attempt is still in flight, so the receiver handles the same delivery twice. The one time we moved that value, the change reached fresh installs only.

**What we did.** One helper that creates, then converges the existing row via `updateQueue`. Options that cannot be changed after creation are a separate argument, so the distinction is visible at every call site, and a lint rule forbids bare `createQueue` so the next option added cannot be inert everywhere the queue already exists.

**The corollary that still catches us.** Converging handles a *changed* option but not a *removed* one. Delete a line from a queue declaration and databases with an existing row keep the old value while new ones take pg-boss's default, which is the same split by age in a new costume.

## 7. Which queue options can you actually change after creation?

All but two. `policy` and `partition` throw. The other eleven update, but through three different SQL paths, and passing `null` does three different things.

pg-boss 12.26.4 and 12.27.0 · statement byte-identical between them

| Option | Column | SQL semantic | Changeable? |
| --- | --- | --- | --- |
| retryLimit | retry_limit | COALESCE | yes |
| retryDelay | retry_delay | COALESCE | yes |
| retryBackoff | retry_backoff | COALESCE | yes |
| retryDelayMax | retry_delay_max | jsonb_exists | yes, with a catch |
| expireInSeconds | expire_seconds | COALESCE | yes |
| retentionSeconds | retention_seconds | COALESCE | yes |
| deleteAfterSeconds | deletion_seconds | COALESCE | yes |
| warningQueueSize | warning_queued | COALESCE | yes |
| heartbeatSeconds | heartbeat_seconds | jsonb_exists | yes |
| notify | notify | COALESCE (absent before 12.21.0) | yes |
| deadLetter | dead_letter | conditional assignment | yes |
| policy | policy | absent from the statement | no, throws |
| partition | partition | absent from the statement | no, throws |

`deadLetter` is the odd one out and belongs to neither semantic class. It arrives as a separate function argument rather than through the options JSONB, and the whole assignment clause is omitted from the generated SQL when the caller passes nothing.

**Passing null has three different outcomes.** The SQL story and the API story diverge: for most options the JS validation layer rejects `null` before it ever reaches the statement.

- **Throws:** `retryLimit`, `retryDelay`, `retryBackoff`, `expireInSeconds`, `retentionSeconds`, `notify`
- **Silently ignored:** `deleteAfterSeconds`, `warningQueueSize`
- **Actually set to null:** `retryDelayMax`, `heartbeatSeconds`

Two options ignore `null` silently: `deleteAfterSeconds` and `warningQueueSize`. The rule is the same either way: **always pass the value you want, including when that value is the default.**

**`retryDelayMax` cannot be updated on its own.** On a queue already created with `retryBackoff: true`:

```
updateQueue(q, { retryDelayMax: 200 })
  -> throws: retryDelayMax can only be set if retryBackoff is true

updateQueue(q, { retryBackoff: true, retryDelayMax: 200 })
  -> stored: 200
```

The guard reads the options object it was handed rather than the queue row, so it cannot see the `retryBackoff: true` already stored. You have to resend it alongside.

**One good piece of news: `policy` and `partition` refuse loudly.** Being absent from the UPDATE could reasonably be read as “silently ignored.” It is not. Both throw: `queue policy cannot be changed after creation` and `queue partitioning cannot be changed after creation`. That is the friendlier failure, and it is what makes the next footgun tractable.

## 8. How do you change a queue's policy on an existing install?

You delete and recreate the queue, which drops its jobs, and you hold your own advisory lock while you do it. There is no in-place path.

pg-boss 12.x · destructive · unprotected by default

`policy` is absent from `updateQueue` and `createQueue` is `ON CONFLICT DO NOTHING`, so changing a policy in code gives new databases the new semantics and leaves every existing one on the old. Given footgun 1, that means an install can be silently running without the dedup you think you configured.

The race is the part worth planning for. pg-boss takes advisory locks inside `createQueue` and `deleteQueue` under *different keys*, and takes no lock at all for reading `pgboss.queue`. A read-then-delete-then-create sequence is therefore protected by nothing pg-boss does. If your API and worker processes boot simultaneously, as ours do, that race is routine rather than theoretical. `pgboss.delete_queue`, the SQL function, on an already-deleted queue raises SQLSTATE `22004`, which is what losing the race looks like. Note the JS `boss.deleteQueue()` swallows the cold-cache case and returns quietly, so you will only see 22004 on the warm-cache race this describes, not by calling it twice from a fresh process.

**What we did.** Boot recreates the queue when the live policy differs from the one in code, reads the policy back, and refuses to start rather than run with dedup silently disabled. The whole check, recreate and verify sequence is held under one advisory lock we own rather than one of pg-boss's.

**The cost.** Recreating drops that queue's jobs. We accepted it for exactly one queue because its work is re-runnable integrity checks with a 24-hour retention, so the worst case is losing up to a day of that history plus whatever was in flight at restart. If the queue carries work you cannot re-derive, drain it first or accept that the policy is fixed for the life of the database.

## 9. What are the pg-boss job states?

Six, and `expired` is not one of them. An expired job is failed through the same path as any other failure, so it lands in `retry` while retries remain and reaches `failed` only once they are exhausted. The default `retryLimit` is 2, so on a default queue it never goes straight to `failed`.

pg-boss 12.x · identical on 12.18.2, 12.26.4, 12.27.0

```
SELECT enum_range(NULL::pgboss.job_state);

                    enum_range
---------------------------------------------------
 {created,retry,active,completed,cancelled,failed}
(1 row)
```

The declaration order is load-bearing rather than cosmetic. Postgres enums compare in declaration order, which is what lets `job_i6` express “not yet finished” as `state <= 'active'`. Confirmed behaviorally on all three versions: on an exclusive queue a send is refused while the holder is `created` and while it is `active`, and accepted immediately once it is `completed` or `failed`.

We published an API response enum that listed `expired`, a state that never arrives, and omitted `retry` and `cancelled`, two that genuinely do. A client coding against that spec would poll forever for one and never handle the others.

**What we did.** Prose about job states is generated from constants rather than retyped, and a test pins our API's documented state enum against `enum_range` on the live database, so a rename upstream fails the build instead of shipping.

## 10. Why did a fatal boot error exit 0 and report success?

Because pg-boss's intervals are not `unref`'d, so a running boss holds the Node event loop open. That decides which way a swallowed boot error fails: hang, or exit clean.

pg-boss 12.x · ESM · measured in the container we run in production

A top-level `await` rejection in an ESM entrypoint is delivered as `uncaughtException`. If you have a keep-alive handler that swallows those, what happens next is decided entirely by pg-boss:

- **pg-boss running:** its intervals hold the event loop open. The process survives, the container reports `Up`, and the HTTP port is never bound. A healthy-looking container serving nothing.
- **pg-boss failed to start:** nothing holds the loop, Node drains it and **exits 0**. A fatal boot error reporting clean success to every orchestrator watching.

The second is the dangerous one. A readiness probe catches the hang. Nothing catches exit 0.

**A related ordering bug in the same area.** Starting job consumers before the boot phase finishes means a job can execute before the process has finished starting. If a later registration then throws, the process exits 1 without stopping pg-boss, stranding that job in `active` on an exclusive queue with `expireInSeconds: 3600`. That reproduces the exact 409 from footgun 1, with nothing running. An hour is the floor, not the ceiling: when the job finally expires it is failed through the normal path, so it moves to `retry`, which `job_i6` also covers, and keeps the slot until a worker drains it.

**What we did.** A two-phase boot guard: until the HTTP server is listening, an unhandled error logs fatal and exits 1; after that, the prior keep-alive posture is unchanged. Consumers start only once boot has finished.

## 11. Why didn't `deleteAfterSeconds` clean up my backlog?

Because it is measured from `completed_on`, so it can only reach jobs that finished. Queued jobs are governed by a second clock that defaults to fourteen days.

pg-boss 12.27.0 · two clocks, one of them easy to miss

The maintenance deletion statement has two branches, and tuning only ever touches the first one:

```
DELETE FROM pgboss.job
WHERE name = ANY(ARRAY['your-queue']::text[])
  AND (
    (deletion_seconds > 0 AND completed_on + deletion_seconds * interval '1s' < now())
    OR
    (state < 'active' AND keep_until < now())
  )
```

A job that has not reached a terminal state has no `completed_on`, so the first branch cannot match it. The second branch is the one that covers those rows. `job_state` is an enum declared in the order `created, retry, active, completed, cancelled, failed`, so `state < 'active'` means `created` or `retry`. Those leave on `keep_until`, set from `retentionSeconds`, whose default is **fourteen days** against `deleteAfterSeconds`' seven.

So a queue tuned to a one-hour deletion window still holds a backlog for a fortnight, and the tighter the deletion window the wider that gap gets. If a consumer falls behind, the rows piling up in `created` are outside the reach of the setting you tuned. Lower `retentionSeconds` alongside it.

We measured what that costs when it compounds with a stalled vacuum: [a job table that doubled its heap at an identical completed workload](https://agledger.ai/blog/postgres-queue-retention-xmin-horizon/).

These are the eleven that bit us repeatedly across outbound delivery, webhook fanout and maintenance schedules. Three have a corresponding GitHub issue; the rest are behaviors read out of the source rather than bugs anyone has filed. pg-boss is the Postgres-native queue we picked and would pick again, and the maintainer is responsive. If you found a cleaner pattern for any of these, open an issue on [pg-boss](https://github.com/timgit/pg-boss) or send us a note.

Everything measured here is behavioral, taken on a single local instance. That is the right instrument for “does this row exist, in this state, after this call” and the wrong one for throughput, so there is no benchmark for what `policy: 'exclusive'` costs against a standard queue. We have written separately about [benchmarking this stack](https://agledger.ai/blog/agledger-performance-at-scale/).

## Sources & further reading

- [pg-boss on GitHub](https://github.com/timgit/pg-boss) (MIT, maintained by [timgit](https://github.com/timgit))
- pg-boss issue [#535](https://github.com/timgit/pg-boss/issues/535)  - worker stops with singleton key in retry + created and batch > 1; fixed in 11.0.8 / 10.4.0
- pg-boss issue [#548](https://github.com/timgit/pg-boss/issues/548)  - replace-vs-discard semantics, closed 2026-07-02
- pg-boss issue [#81](https://github.com/timgit/pg-boss/issues/81)  - historical singleton context (closed 2018)
- [pg-boss release notes](https://github.com/timgit/pg-boss/releases)  - v10, v11, v12 breaking changes
- [PostgreSQL `information_schema.columns`](https://www.postgresql.org/docs/current/infoschema-columns.html)  - reference for the schema sentinel pattern
- [PostgreSQL: The Information Schema](https://www.postgresql.org/docs/current/information-schema.html)  - background on `information_schema` vs `pg_catalog` for the startup-sentinel pattern
- [pg-boss v10.0.0 release notes](https://github.com/timgit/pg-boss/releases/tag/10.0.0)  - snake_case column rename, queue policies, partitioned tables
- [pg-boss v11.0.0 release notes](https://github.com/timgit/pg-boss/releases/tag/11.0.0)  - archive table removed, retention semantics changed
- [Brandur Leach: Postgres as a queue](https://brandur.org/postgres-queues)  - canonical piece on the broader pattern pg-boss implements
- [Brandur Leach: Implementing Stripe-like idempotency keys](https://brandur.org/idempotency-keys)
- pg-boss [`src/plans.ts`](https://github.com/timgit/pg-boss/blob/master/src/plans.ts)  - the queue and index DDL, and the source for every mechanism claim in Footguns 1 and 5 through 9. Read it at the tag matching your version rather than on `master`
- [PostgreSQL: enumerated types](https://www.postgresql.org/docs/current/datatype-enum.html)  - “the ordering of the values in an enum type is the order in which the values were listed when the type was created”, which is what makes `state <= 'active'` mean what it means in Footgun 9

## Related

post[Cutting PostgreSQL audit-report query time 44% with GROUPING SETS and materialized CTEs](https://agledger.ai/blog/postgres-grouping-sets-audit-report/)

post[Lessons from an in-place PostgreSQL 17 to 18 upgrade](https://agledger.ai/blog/postgresql-17-18-in-place-upgrade-uuidv7/)

post[Three ways to measure a Postgres queue table, three different answers](https://agledger.ai/blog/postgres-queue-table-three-measurements/)

post[The advisory-lock self-deadlock Postgres cannot see](https://agledger.ai/blog/postgres-advisory-lock-self-deadlock/)

AGLedger is change control for AI agents: a self-hosted signed ledger of automated work, hash-chained and Ed25519-signed. pg-boss is part of how we get there, which is why queue behavior this far down gets this much attention: a record is only worth something if it still holds up once someone disputes it. That property has a name, [tamper-evident logging](https://agledger.ai/tamper-evident-logging/), and it is what [an AI agent's audit trail](https://agledger.ai/ai-agent-audit-trail/) has to be built from. [Learn more](https://agledger.ai/).
