Sequence-generated primary keys built on a four-byte integer will eventually collide with the upper bound of the type if rows keep arriving. The failure mode is an error, not silent corruption, but the fix is a table rewrite — so the practical work is split between watching for the wall and, if you still have runway, walking a table over to bigint without blocking writes.

Two ways to get to the same wall

The traditional approach uses serial:

1

2

3

4

CREATE TABLE tab (

   id serial PRIMARY KEY,

   ...

);

That is shorthand: it creates a four-byte integer column with a DEFAULT, plus a sequence behind it.

1

2

3

4

5

6

\d tab

                             Table "public.tab"

Column |  Type   | Collation | Nullable |             Default

--------+---------+-----------+----------+---------------------------------

id     | integer |           | not null | nextval('tab_id_seq'::regclass)

...

The SQL-standard alternative is an identity column:

1

2

3

4

CREATE TABLE tab (

   id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

   ...

);

This too creates a sequence internally. Either way, sustained inserts will eventually produce:

1

ERROR:  nextval: reached maximum value of sequence "tab_id_seq" (2147483647)

Table size is not the deciding factor. A table that sees a steady mix of INSERT and DELETE consumes sequence values just as quickly while staying small, so an application with high churn can hit the ceiling on a modest data set.

Why the obvious conversion hurts

The textbook remedy is to widen the column to eight-byte bigint:

1

ALTER TABLE tab ALTER id TYPE bigint;

Because the on-disk representation differs between the two types, PostgreSQL must rewrite the whole table, and rewrite the indexes with it. Two consequences follow. First, you need free space to hold both the old and the new copies simultaneously. Second, the operation holds an ACCESS EXCLUSIVE lock, which blocks all concurrent access to the table. On a large table, that is downtime, which is precisely what the operator who ignored the original sizing advice was trying to avoid.

Know your headroom

Most integer-keyed tables never overflow, so converting everything defensively is wasted pain. Measurement is cheaper. The query below inspects every sequence-generated key in the current database and reports how much of the range is consumed:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

SELECT a.attrelid::regclass AS table_name,

       a.attname            AS column_name,

       s.seqrelid::regclass AS sequence_name,

       round(

          /* cast to "numeric" so we can round to 2 decimal places */

          CAST (

             /*

              * Divide the current sequence position by the maximal

              * value for the columns's data type.  Multiply with

              * 100 to get a percentage.

              */

             seq.seq::float8 * 100.0::float8 /

             CASE a.atttypid

                WHEN 'smallint'::regtype THEN 32768::float8

                WHEN 'integer'::regtype  THEN 2147483646::float8

                WHEN 'bigint'::regtype   THEN 9223372036854775808::float8

             END

             AS numeric

          ),

          2

       ) AS percent_used

FROM pg_sequence AS s

   /* objects hat the sequence depends on */

   JOIN pg_depend AS d

      ON d.classid = 'pg_class'::regclass

         AND d.objid = s.seqrelid

         AND d.objsubid = 0

   /* the column the sequence depends on */

   JOIN pg_attribute AS a

      ON d.refclassid = 'pg_class'::regclass

         AND a.attrelid = d.refobjid

         AND a.attnum = d.refobjsubid

   /* the next value from the sequence */

   CROSS JOIN LATERAL nextval(s.seqrelid) AS seq

ORDER BY percent_used DESC;

It covers both serial and identity columns. A percent_used near 100 means the column is near the limit. The query has one blind spot: it cannot see a column whose sequence has no dependency recorded on it. If you created the sequence separately from the column, you can register that link by hand:

1

ALTER SEQUENCE seq OWNED BY tab.id;

The query reads the current position with nextval(), which advances the sequence and leaves a gap. If that is objectionable, adjust your expectations about gaps in sequences. Starting with PostgreSQL v18, pg_get_sequence_data() — undocumented — reads the sequence position without invoking nextval().

Converting without a long outage

If the limit is visible but not yet reached, the migration can be staged so that no single step holds the table for long.

Add the new column and keep it filled

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

BEGIN;

SET LOCAL lock_timeout = '1s';

ALTER TABLE tab ADD id2 bigint;

CREATE FUNCTION copy_id() RETURNS trigger

   LANGUAGE plpgsql AS

$$BEGIN

   NEW.id2 = NEW.id;

   RETURN NEW;

END;$$;

CREATE TRIGGER copy_id BEFORE INSERT OR UPDATE ON tab

   FOR EACH ROW EXECUTE FUNCTION copy_id();

COMMIT;

Adding a column is a metadata-only change, but it still needs a brief ACCESS EXCLUSIVE lock — which is why lock_timeout is set for the transaction. While the lock is held, the trigger is created so that every subsequent row has id copied into id2. The trigger does not handle updates to the primary key; that is not a workflow you should have in the first place. Run this when no long-running transactions are in flight, or you will queue behind them and fail the timeout.

Backfill in batches

The trigger covers new rows; existing rows still need filling. One large UPDATE would double the table's size in dead tuples, and clearing that would mean VACUUM (FULL) — another operation that blocks everything. Batching avoids both problems, with a plain VACUUM between batches to reclaim space for the next one.

1

2

3

4

5

6

7

8

9

10

11

12

UPDATE tab SET id2 = id

WHERE id2 IS NULL

  AND id < 1000000;

VACUUM tab;

UPDATE tab SET id2 = id

WHERE id2 IS NULL

  AND id BETWEEN 1000001 AND 2000000;

VACUUM tab;

...

The batch size of one million rows here is arbitrary. A batch UPDATE can deadlock against concurrent modifications, so pick the size with that risk in mind and be prepared to retry any statement that fails on a deadlock.

Constrain the new column

Setting NOT NULL in one step scans for NULLs while holding ACCESS EXCLUSIVE. A check constraint avoids that scan later, and a NOT VALID constraint can be created and validated without a long lock. In PostgreSQL v18 and later the check constraint is unnecessary, since a NOT NULL constraint can itself be added NOT VALID and validated afterwards.

1

2

3

4

5

6

7

8

9

ALTER TABLE tab ADD CONSTRAINT tab_id2_notnull CHECK (id2 IS NOT NULL) NOT VALID;

ALTER TABLE tab VALIDATE CONSTRAINT tab_id2_notnull;

ALTER TABLE tab ALTER id2 SET NOT NULL;

ALTER TABLE tab DROP CONSTRAINT tab_id2_notnull;

CREATE UNIQUE INDEX CONCURRENTLY tab_pkey2 ON tab (id2);

Swap the primary key

This step takes ACCESS EXCLUSIVE again, but only for fast metadata operations. Any foreign keys that point at the table must be dropped beforehand.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

BEGIN;

SET LOCAL lock_timeout = '1s';

/* works only if not referenced by a foreign key */

ALTER TABLE tab DROP id;

DROP TRIGGER copy_id ON tab;

DROP FUNCTION copy_id();

ALTER TABLE tab RENAME id2 TO id;

ALTER TABLE tab ADD PRIMARY KEY USING INDEX tab_pkey2;

/* start the auto-generated column values with a safe value */

ALTER TABLE tab ALTER COLUMN id

   ADD GENERATED ALWAYS AS IDENTITY (START WITH 2147483648);

COMMIT;

Afterwards, recreate the foreign keys. Add them NOT VALID and validate them as a separate step, which keeps the lock brief.

Four-byte sequence keys are a liability you can measure rather than guess at. A monitoring query tells you which columns are genuinely close to the boundary, and for those, the staged bigint conversion moves the primary key without forcing the table out of service.