Writestop › demo

See it work

Run one read-only query on your own Postgres, paste the output, and see the days left before each ID sequence and transaction ID counter reaches its limit; nothing you paste is stored.

Input

2 ID columns · 1 database

Show the raw query output

4 rows of the Writestop query's output: 2 ID columns fed by a sequence, and transaction and multixact ID age for database shop.

Output
 kind |       name       |  type   |       sequence       |    used    |         max         | per_day  | vacuum_at |        since         
------+------------------+---------+----------------------+------------+---------------------+----------+-----------+----------------------
 id   | public.events.id | bigint  | public.events_id_seq | 8412559031 | 9223372036854775807 | 41208113 |           | 2026-10-06 04:12 UTC
 id   | public.orders.id | integer | public.orders_id_seq | 2104481029 |          2147483647 |  2312040 |           | 2026-10-06 04:12 UTC
 mxid | shop             |         |                      |    1048576 |          2144483647 |     4096 | 400000000 | 2026-10-06 04:12 UTC
 xid  | shop             |         |                      |  498213557 |          2144483647 | 14024704 | 200000000 | 2026-10-06 04:12 UTC
(4 rows)

Output

3 limits checked: 1 act now, 1 plan, 1 fine.

  1. Act now public.orders.id 18 days left

    integer · 97.9% used: 2,104,481,029 of 2,147,483,647 · 2.3M a day

    What to do

    Migrate the column to bigint: check its lock risk first.

    For a serial column, also run ALTER SEQUENCE public.orders_id_seq AS bigint; Changing a serial column's type leaves its sequence at the integer limit.

  2. Plan shop: transaction IDs 117 days left

    23.2% used: 498,213,557 of 2,144,483,647 · 14M a day

    Multixact IDs: under 0.1% used: 1,048,576 of 2,144,483,647 · 4.1K a day, over 100 years left

    What to do

    The age is above autovacuum_freeze_max_age (200,000,000), so autovacuum should already be freezing this database: something is holding it back.

    Check long-running transactions (backend_xmin in pg_stat_activity), replication slots (xmin and catalog_xmin in pg_replication_slots) and prepared transactions (pg_prepared_xacts). Then run VACUUM (FREEZE) on the tables with the oldest age(relfrozenxid).

    Docs: wraparound, VACUUM.

  3. Fine public.events.id over 100 years left

    bigint · under 0.1% used: 8,412,559,031 of 9,223,372,036,854,775,807 · 41M a day

Rates are estimated from activity since 2026-10-06 04:12 UTC. Act now: under 30 days left. Plan: 30 to 180. Fine: over 180.

What Writestop did

  • Writestop: Read every sequence and ID column's type limit and current value

    By hand: know which columns are integer and what their limits are

  • Writestop: Estimated each insert rate and projected the day each limit is reached

    By hand: a query shows today's number, not the day you run out

  • Writestop: Checked transaction ID age against the wraparound limit

    By hand: remember to run the check every week

Want this watching your database? The first 100 get $9/database/mo. Nothing is charged when you join.

Hold my founding spot

Hold a founding spot

The first 100 on the waitlist get $9/database/mo, locked for as long as you stay subscribed. Then it's $19.

We store your email, a source tag, your consent and the signup time in Cloudflare D1, to email you about Writestop. Privacy

BVL privacy policy

Try it on your own database

Connect to the database you want checked, with psql or any SQL client. The query reads the catalog and statistics, never table rows.

1. Run this query

Show the query to run
SQL
-- Writestop: how close each ID sequence and transaction ID counter is to its limit.
-- Read-only. Run it in each database you want checked; it never reads table rows.
WITH fed AS (  -- sequences and the columns they feed: serial, identity, nextval() defaults
  SELECT objid AS seq, refobjid AS tbl, refobjsubid AS col FROM pg_depend
  WHERE classid = 'pg_class'::regclass AND refclassid = 'pg_class'::regclass
    AND deptype IN ('a', 'i') AND refobjsubid > 0
  UNION
  SELECT d.refobjid, ad.adrelid, ad.adnum
  FROM pg_depend d JOIN pg_attrdef ad ON ad.oid = d.objid
  WHERE d.classid = 'pg_attrdef'::regclass AND d.refclassid = 'pg_class'::regclass
), ids AS (
  SELECT f.seq, f.tbl, sn.nspname AS seq_schema, q.relname AS seq_name,
         format('%I.%I.%I', tn.nspname, t.relname, a.attname) AS name, a.atttypid
  FROM fed f
  JOIN pg_class q ON q.oid = f.seq AND q.relkind = 'S'
  JOIN pg_namespace sn ON sn.oid = q.relnamespace
  JOIN pg_class t ON t.oid = f.tbl AND NOT t.relispartition
  JOIN pg_namespace tn ON tn.oid = t.relnamespace
  JOIN pg_attribute a ON a.attrelid = f.tbl AND a.attnum = f.col
  WHERE a.atttypid IN ('int2'::regtype, 'int4'::regtype, 'int8'::regtype)
), win AS (
  SELECT coalesce(stats_reset, pg_postmaster_start_time()) AS since
  FROM pg_stat_database WHERE datname = current_database()
), slru AS (  -- commit-log pages started: block_size * 4 XIDs, or block_size / 4 MXIDs, each
  SELECT CASE WHEN name IN ('Xact', 'transaction') THEN 'xid' ELSE 'mxid' END AS kind,
         blks_zeroed * CASE WHEN name IN ('Xact', 'transaction') THEN 4.0 ELSE 0.25 END
           * current_setting('block_size')::int AS ids,
         coalesce(stats_reset, pg_postmaster_start_time()) AS since
  FROM pg_stat_slru
  WHERE name IN ('Xact', 'transaction', 'MultiXactOffset', 'multixact_offset')
)
SELECT 'id' AS kind, i.name, format_type(i.atttypid, NULL) AS type,
       format('%I.%I', i.seq_schema, i.seq_name) AS sequence,
       s.last_value AS used,
       least(s.max_value, CASE i.atttypid WHEN 'int2'::regtype THEN 32767
         WHEN 'int4'::regtype THEN 2147483647 ELSE 9223372036854775807 END) AS max,
       round((SELECT coalesce(sum(st.n_tup_ins), 0) FROM pg_stat_all_tables st
              WHERE st.relid IN (SELECT coalesce(p.relid, i2.tbl) FROM ids i2
                LEFT JOIN LATERAL pg_partition_tree(i2.tbl) p ON true WHERE i2.seq = i.seq))
             * 86400 / greatest(extract(epoch FROM now() - w.since), 1))::bigint AS per_day,
       NULL::bigint AS vacuum_at,
       to_char(w.since AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI "UTC"') AS since
FROM ids i
JOIN pg_sequences s ON s.schemaname = i.seq_schema AND s.sequencename = i.seq_name
CROSS JOIN win w
WHERE s.increment_by > 0
UNION ALL
-- Postgres stops assigning XIDs and MXIDs at an age of 2^31 - 1 - 3,000,000.
SELECT z.kind, d.datname, NULL, NULL,
       CASE z.kind WHEN 'xid' THEN age(d.datfrozenxid) ELSE mxid_age(d.datminmxid) END,
       2144483647,
       round(z.ids * 86400 / greatest(extract(epoch FROM now() - z.since), 1))::bigint,
       current_setting(CASE z.kind WHEN 'xid' THEN 'autovacuum_freeze_max_age'
         ELSE 'autovacuum_multixact_freeze_max_age' END)::bigint,
       to_char(z.since AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI "UTC"')
FROM pg_database d CROSS JOIN slru z
WHERE d.datname = current_database()
ORDER BY 1, 2;

2. Paste the output

psql's default output, psql --csv or tab-separated, up to 20,000 characters.

Limits. Rates come from one snapshot of Postgres statistics, so they are estimates. Nothing is sent to Slack.