# Por qué los IDs de Postgres se saltan números y cómo corregir los errores "duplicate key" después de una importación

De dónde vienen los huecos en las columnas serial e identity de PostgreSQL (rollbacks, ON CONFLICT, caídas, CACHE), por qué los inserts fallan con duplicate key value violates unique constraint después de importar datos, una consulta que reajusta todas las secuencias, qué pasa cuando se agotan los IDs integer y numeración sin huecos para facturas. Cada sentencia reproducida en Postgres 17.11.

**Hay dos preguntas sobre los IDs de Postgres que aparecen una y otra vez. "¿Por qué mis IDs saltaron de 5 a 34?" y "¿Por qué todos los inserts fallan con `duplicate key value violates unique constraint \"..._pkey\"` justo después de importar mis datos?". Las dos vienen del mismo objeto: la secuencia que hay detrás de tu columna `serial` o identity. Una secuencia reparte números sin importarle si los usas, así que los huecos son normales. Y solo conoce los números que ella misma repartió, así que las filas que cargas con IDs explícitos la dejan atrás. Este artículo reproduce cada tipo de hueco, corrige el error de importación para una tabla y luego para todas las tablas a la vez, muestra qué pasa cuando se agota un ID `integer` y construye una numeración sin huecos para el único lugar donde quizá de verdad la necesites. Todas las sentencias y todas las salidas de abajo vienen de un PostgreSQL 17.11 local, la versión mayor que ejecuta Relyt One.**

## La versión corta

| Síntoma                                                                          | Causa                                                                       | Solución                                                   |
| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Los IDs se saltan algunos números                                                | Inserts revertidos o fallidos, `ON CONFLICT`                                | Nada. Las secuencias no son sin huecos por diseño          |
| Los IDs saltan hasta 32 después de una caída (con la caché predeterminada)       | Las secuencias registran valores por adelantado en el WAL                   | Nada. No le des significado a los valores de los IDs       |
| Los IDs de distintas sesiones se intercalan o saltan según el tamaño de la caché | `CACHE` mayor que 1                                                         | Usa el `CACHE 1` predeterminado si el orden importa        |
| `duplicate key value violates unique constraint` después de una importación      | Las filas se cargaron con IDs explícitos; la secuencia sigue empezando en 1 | Mueve la secuencia más allá de `max(id)` con `setval`      |
| `nextval: reached maximum value of sequence`                                     | Una columna `serial` es un `integer` de 32 bits                             | Cambia la secuencia **y** la columna a `bigint`            |
| Necesitas números de factura sin huecos                                          | Las secuencias no pueden dártelos                                           | Una fila contador que se actualiza en la misma transacción |

## De dónde vienen los huecos

La documentación de PostgreSQL lo dice sin rodeos: el valor que devuelve `nextval` "is not reclaimed for re-use if the calling transaction later aborts" (no se recupera para reutilizarlo si la transacción que lo pidió aborta después), y "sequence objects cannot be used to obtain 'gapless' sequences." (los objetos secuencia no se pueden usar para obtener secuencias "sin huecos"). Es una concesión deliberada. Si una transacción revertida pudiera devolver su número, todas las demás transacciones que piden un número tendrían que esperar para ver si lo hizo. Así aparecen los huecos en la práctica.

### Rollbacks, inserts fallidos y ON CONFLICT

```sql
CREATE TABLE orders (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE
);

INSERT INTO orders (email) VALUES ('a@example.com');           -- toma el 1

BEGIN;
INSERT INTO orders (email) VALUES ('b@example.com');           -- toma el 2
ROLLBACK;                                                      -- el 2 se pierde

INSERT INTO orders (email) VALUES ('c@example.com');           -- toma el 3
INSERT INTO orders (email) VALUES ('a@example.com')
  ON CONFLICT (email) DO NOTHING;                              -- gasta el 4
INSERT INTO orders (email) VALUES ('a@example.com');           -- gasta el 5, falla
INSERT INTO orders (email) VALUES ('d@example.com');           -- toma el 6

SELECT id, email FROM orders ORDER BY id;
```

```text
 id |     email
----+---------------
  1 | a@example.com
  3 | c@example.com
  6 | d@example.com
(3 rows)
```

Tres filas, tres huecos. El insert revertido tomó el 2. El insert que falló por la restricción única de `email` tomó el 5 antes de fallar: Postgres construye la fila completa, incluido el ID predeterminado, antes de comprobar las restricciones. `ON CONFLICT DO NOTHING` tomó el 4 sin insertar nada, por el mismo motivo. La documentación lo dice explícitamente: `INSERT ... ON CONFLICT` calcula la fila, "including doing any required nextval calls, before detecting any conflict." (incluidas las llamadas a nextval necesarias, antes de detectar cualquier conflicto). Si tu aplicación hace upsert de las mismas filas una y otra vez, la secuencia puede subir mucho más rápido que el número de filas. (Tratamos los upserts en detalle en [INSERT ... ON CONFLICT vs MERGE](/blog/es/postgres-upsert-on-conflict-vs-merge)).

### Caídas: hasta 32 números de golpe (más con caché)

Escribir un registro WAL por cada `nextval` sería lento, así que Postgres registra la secuencia por adelantado: cuando escribe un registro, anota un valor 32 números más adelante y reparte los números intermedios sin volver a registrar. La constante es `SEQ_LOG_VALS` en el código fuente del servidor, y el comentario que la acompaña dice: "In the event of crash we can lose (skip over) as many values as we pre-logged." (En caso de caída podemos perder, es decir, saltarnos, tantos valores como hayamos registrado por adelantado).

![Una secuencia después de cinco llamadas a nextval. La página en memoria dice last value 5. El registro WAL escrito por la primera llamada ya dice 33. Después de una caída, la recuperación reproduce el WAL, así que el siguiente valor es 34 y del 6 al 33 nunca se usan.](/blog/assets/daf622c916ee8612.svg)

Puedes verlo en acción. `log_cnt` es cuántos valores registrados por adelantado quedan:

```sql
CREATE TABLE events (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  note text
);
INSERT INTO events (note) SELECT 'before crash' FROM generate_series(1, 5);
SELECT last_value, log_cnt FROM events_id_seq;
```

```text
 last_value | log_cnt
------------+---------
          5 |      28
(1 row)
```

Ahora tira el servidor sin un apagado limpio (`pg_ctl stop -m immediate`), vuelve a arrancarlo e inserta una fila más:

```text
LOG:  database system was not properly shut down; automatic recovery in progress
```

```sql
INSERT INTO events (note) VALUES ('after crash') RETURNING id;
```

```text
 id
----
 34
(1 row)
```

La recuperación reprodujo el WAL, el WAL decía 33, así que el siguiente ID es 34. Los números del 6 al 33 nunca se usaron. Postgres también escribe un registro nuevo de 32 valores en el primer `nextval` después de cada checkpoint, así que cuántos números pierdes depende de en qué punto de esa ventana caiga la caída. Los 32 se suman a la caché: una secuencia con `CACHE 20` (siguiente sección) registra 20 + 32 valores por adelantado y puede perder esa cantidad.

Un apagado limpio escribe el estado real en disco, y en nuestra prueba un reinicio normal continuó en el siguiente número. Era un servidor Postgres simple que detuvimos nosotros mismos; cómo ocurren los reinicios en un servicio gestionado depende del servicio, así que trata cualquier reinicio como un posible salto.

### CACHE: cada sesión toma un bloque

Una secuencia creada con `CACHE 20` le da a cada sesión 20 números a la vez. Los números que una sesión no usa se pierden cuando se desconecta:

```sql
CREATE SEQUENCE ticket_seq CACHE 20;
SELECT nextval('ticket_seq');    -- la sesión 1 reserva 1..20
\c
SELECT nextval('ticket_seq');    -- la sesión 2 reserva 21..40
```

```text
 nextval
---------
       1
(1 row)

You are now connected to database "postgres" as user "postgres".
 nextval
---------
      21
(1 row)
```

Con caché, la documentación también advierte que los números de distintas sesiones ya no se reparten en orden temporal: la sesión A obtiene 1, luego la sesión B obtiene 21, y solo después A obtiene 2. El valor predeterminado es `CACHE 1`, que es lo que usan las columnas `serial` e identity a menos que lo cambies.

Ninguno de estos huecos es un bug, y ninguno necesita corrección. La corrección está en tu cabeza: un ID dice "esta fila es distinta de aquella", no "este fue el 6.º pedido" ni "hay 6 pedidos".

## "duplicate key value violates unique constraint" después de una importación

Este sí es un bug real, y suele aparecer el primer día después de una migración: exportaste una tabla de una base de datos, la cargaste en otra y el primer insert de la aplicación falla. Aquí está todo en tres sentencias:

```sql
CREATE TABLE customers (
  id   serial PRIMARY KEY,
  name text NOT NULL
);

-- lo que hace una importación CSV, un seed de ORM o una copia desde otra base de datos:
COPY customers (id, name) FROM stdin;
1	Ada
2	Grace
3	Edsger
\.

INSERT INTO customers (name) VALUES ('Barbara');
```

```text
ERROR:  duplicate key value violates unique constraint "customers_pkey"
DETAIL:  Key (id)=(1) already exists.
```

Las filas traen sus propios IDs, así que `COPY` nunca llamó a `nextval`. La secuencia sigue creyendo que no ha repartido nada, le da el 1 a la fila nueva y la clave primaria la rechaza. Las columnas identity se comportan igual: `COPY` escribe los IDs de la entrada incluso en una columna `GENERATED ALWAYS`, "like the INSERT option OVERRIDING SYSTEM VALUE" (como la opción OVERRIDING SYSTEM VALUE de INSERT), y la secuencia no se mueve.

Un detalle que lo hace confuso: cada insert fallido igualmente consume un número. Reintenta tres veces y el cuarto insert funciona, porque la secuencia por fin ha avanzado más allá de los IDs importados. Por eso el error a veces "se arregla solo" en desarrollo, donde importaste tres filas, y no en producción, donde importaste tres millones.

### Corregir una tabla

Mueve la secuencia al ID más alto de la tabla. `pg_get_serial_sequence` encuentra la secuencia que hay detrás de una columna `serial` o identity, así que no necesitas adivinar su nombre:

```sql
SELECT pg_get_serial_sequence('customers', 'id');
SELECT setval(pg_get_serial_sequence('customers', 'id'), max(id)) FROM customers;
INSERT INTO customers (name) VALUES ('Barbara') RETURNING id;
```

```text
 pg_get_serial_sequence
-------------------------
 public.customers_id_seq
(1 row)

 setval
--------
      3
(1 row)

 id
----
  4
(1 row)
```

Hay dos cosas que salen mal con este one-liner. En una tabla vacía, `max(id)` es `NULL`, y `setval` con un argumento `NULL` devuelve `NULL` y en silencio no hace nada. Y `setval(seq, 0)` falla, porque el mínimo predeterminado de una secuencia es 1:

```sql
CREATE TABLE audit_log (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY);
SELECT setval(pg_get_serial_sequence('audit_log', 'id'), max(id)) FROM audit_log;
SELECT setval(pg_get_serial_sequence('audit_log', 'id'), 0);
```

```text
 setval
--------

(1 row)

ERROR:  setval: value 0 is out of bounds for sequence "audit_log_id_seq" (1..9223372036854775807)
```

La forma que funciona con tablas vacías es `setval(seq, COALESCE(max(id), 0) + 1, false)`. El `false` significa "el siguiente `nextval` devuelve exactamente este valor", así que una tabla vacía empieza en 1.

Ten en cuenta además que `setval` no es transaccional: la documentación dice que su cambio es "immediately visible to other transactions, and \[is] not undone if the calling transaction rolls back." (visible de inmediato para otras transacciones, y no se deshace si la transacción que lo llamó hace rollback).

### Corregir todas las secuencias a la vez

Después de una importación masiva quieres hacer esto en todas las tablas. Esta consulta encuentra cada secuencia que pertenece a una columna `serial` o identity, construye un `setval` para cada una, y `\gexec` en `psql` ejecuta las sentencias generadas. Cada `setval` solo mueve una secuencia hacia adelante: toma el mayor entre `max(id) + 1` y el valor que la secuencia repartiría a continuación. Eso importa en una base de datos que ya está en uso. Si borraste las filas más nuevas de una tabla, `max(id) + 1` es menor que la secuencia, y mover la secuencia hacia atrás repartiría IDs que ya se usaron una vez y que quizá sigan referenciados en otro lugar.

```sql
SELECT format(
         'SELECT setval(%1$L, GREATEST('
         'COALESCE((SELECT max(%2$I) FROM %3$I.%4$I), 0) + 1, '
         '(SELECT last_value + is_called::int FROM %1$s)), false);',
         s.oid::regclass, a.attname, n.nspname, t.relname)
FROM pg_class s
JOIN pg_depend d    ON d.classid = 'pg_class'::regclass
                   AND d.objid = s.oid
                   AND d.refclassid = 'pg_class'::regclass
                   AND d.deptype IN ('a', 'i')   -- serial / identity
JOIN pg_class t     ON t.oid = d.refobjid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
ORDER BY 1
\gexec
```

En nuestra base de datos de prueba generó y ejecutó una sentencia por tabla. Aquí van dos de ellas (salida abreviada), la `audit_log` vacía y la `customers` importada:

```text
SELECT setval('audit_log_id_seq', GREATEST(COALESCE((SELECT max(id) FROM public.audit_log), 0) + 1, (SELECT last_value + is_called::int FROM audit_log_id_seq)), false);
 setval
--------
      1
(1 row)

SELECT setval('customers_id_seq', GREATEST(COALESCE((SELECT max(id) FROM public.customers), 0) + 1, (SELECT last_value + is_called::int FROM customers_id_seq)), false);
 setval
--------
      5
(1 row)
```

Ejecútala después de la importación y antes de dejar entrar el tráfico; una fila insertada entre `max(id)` y `setval` todavía puede chocar. Como el siguiente valor propio de la secuencia forma parte del `GREATEST`, una secuencia con un `MINVALUE` o `START` personalizado mayor que 1 en una tabla vacía se queda donde está en lugar de fallar. `\gexec` sigue adelante después de un error, así que ejecútala con `\set ON_ERROR_STOP on` si quieres que el primer fallo detenga el resto. Hay dos casos que no cubre. Las secuencias que cuentan hacia abajo (`INCREMENT` negativo) necesitan `min` y `LEAST` en su lugar. Y una secuencia que una columna solo usa mediante `DEFAULT nextval('some_seq')`, sin `OWNED BY`, no pertenece a la columna: `pg_get_serial_sequence` devuelve `NULL` para ella, y la consulta de arriba la omite. Corrige esas a mano.

### Qué formas de mover datos dejan atrás a las secuencias

| Cómo llegaron los datos                                                                   | Secuencias                                                                                                                                                                                                                                                                              |
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pg_dump` de toda la base de datos, restaurado con `psql` o `pg_restore`                  | Ajustadas correctamente. El dump contiene un `setval` para cada secuencia                                                                                                                                                                                                               |
| `pg_dump --data-only`                                                                     | Ajustadas correctamente. Los dumps de solo datos también incluyen las llamadas a `setval`                                                                                                                                                                                               |
| `pg_dump -t <table>`                                                                      | Las secuencias que **pertenecen** a la tabla (`serial`, identity) se incluyen con su `setval`. Una secuencia que la tabla solo referencia mediante `DEFAULT nextval(...)` no: restaurar en una base de datos que no la tiene falla, y en una que sí la tiene, esa secuencia no se mueve |
| `COPY` o `\copy` desde CSV, archivos seed de ORM, un script que inserta filas con sus IDs | **No ajustadas.** Ejecuta la corrección de arriba                                                                                                                                                                                                                                       |
| Replicación lógica                                                                        | **No ajustadas.** "Sequence data is not replicated" (los datos de las secuencias no se replican): las secuencias del suscriptor se quedan en su valor inicial hasta que las actualices antes de hacer el cambio                                                                         |
| `TRUNCATE` y recarga                                                                      | `TRUNCATE` no reinicia las secuencias a menos que añadas `RESTART IDENTITY`; recargar los mismos IDs está bien, cargar IDs nuevos más altos necesita la corrección                                                                                                                      |

Comprobamos cada fila con `pg_dump` 17.11. La documentación explica el caso de `DEFAULT nextval(...)`: con `-t`, "pg_dump makes no attempt to dump any other database objects that the selected table(s) might depend upon." (pg_dump no intenta volcar ningún otro objeto de la base de datos del que puedan depender las tablas seleccionadas).

## Cuando la secuencia se agota

`serial` crea una columna `integer` y una secuencia `integer`, así que se detiene en 2.147.483.647. Parece mucho hasta que una tabla que registra eventos o hace upserts masivos gasta números más rápido de lo que guarda filas (mira los huecos de arriba). Así es como se ve el final:

```sql
CREATE TABLE clicks (id serial PRIMARY KEY);
SELECT setval('clicks_id_seq', 2147483646);     -- avance rápido para la demo
INSERT INTO clicks DEFAULT VALUES RETURNING id;
INSERT INTO clicks DEFAULT VALUES RETURNING id;
```

```text
     id
------------
 2147483647
(1 row)

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

Comprueba qué tan cerca estás antes de que ocurra. `pg_sequences` muestra cada secuencia con su tipo y su límite:

```sql
SELECT schemaname, sequencename, data_type, last_value, max_value,
       round(100.0 * last_value / max_value, 2) AS pct_used
FROM pg_sequences
ORDER BY pct_used DESC NULLS LAST, sequencename
LIMIT 3;
```

```text
 schemaname |   sequencename   | data_type | last_value |      max_value      | pct_used
------------+------------------+-----------+------------+---------------------+----------
 public     | clicks_id_seq    | integer   | 2147483647 |          2147483647 |   100.00
 public     | ticket_seq       | bigint    |         40 | 9223372036854775807 |     0.00
 public     | audit_log_id_seq | bigint    |            | 9223372036854775807 |
(3 rows)
```

Fíjate en `ticket_seq` en 40: con `CACHE 20`, `last_value` incluye números que las sesiones han reservado pero todavía no han usado. Y `last_value` es `NULL` para una secuencia que no ha repartido ningún número desde un `setval(..., false)`, como las que la corrección de arriba acaba de tocar; esas se ordenan al final.

Esta consulta solo conoce el límite propio de la secuencia. Si la secuencia es `bigint` pero la columna sigue siendo `integer`, la columna falla primero mientras la consulta muestra casi 0 %. Esa es justo la siguiente trampa.

Cambiar solo la secuencia no basta, porque la columna sigue siendo `integer`:

```sql
ALTER SEQUENCE clicks_id_seq AS bigint;
INSERT INTO clicks DEFAULT VALUES RETURNING id;
```

```text
ERROR:  integer out of range
```

La columna también tiene que cambiar, con `ALTER TABLE clicks ALTER COLUMN id TYPE bigint`. En una tabla grande, esa sentencia reescribe toda la tabla mientras mantiene un bloqueo `ACCESS EXCLUSIVE`, que bloquea lecturas y escrituras durante todo el tiempo que se ejecuta. Nuestro artículo sobre [migraciones que bloquean tu tabla](/blog/es/postgres-migration-locks) mide exactamente este cambio. La corrección barata es la que haces el primer día: usa `bigint` para los IDs.

## Cuando de verdad necesitas números sin huecos

A veces el negocio necesita números sin agujeros; los números de factura son el ejemplo habitual. Una secuencia no puede hacerlo, así que usa una fila que actualizas en la misma transacción que el insert. Con el nivel de aislamiento predeterminado `READ COMMITTED`, el bloqueo de fila hace que las transacciones concurrentes se turnen, y un rollback deshace el incremento junto con la factura:

```sql
CREATE TABLE invoice_counters (
  year    int PRIMARY KEY,
  last_no int NOT NULL
);
INSERT INTO invoice_counters VALUES (2026, 0);

CREATE TABLE invoices (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  year   int NOT NULL,
  no     int NOT NULL,
  amount numeric NOT NULL,
  UNIQUE (year, no)
);

BEGIN;
WITH n AS (
  UPDATE invoice_counters SET last_no = last_no + 1
  WHERE year = 2026
  RETURNING last_no
)
INSERT INTO invoices (year, no, amount)
SELECT 2026, last_no, 120 FROM n
RETURNING id, year, no;
ROLLBACK;
```

Ejecuta la misma transacción otra vez con `COMMIT` en lugar de `ROLLBACK`:

```text
 id | year | no
----+------+----
  2 | 2026 |  1
(1 row)
```

El número de factura vuelve a ser 1 porque el rollback deshizo el contador. El `id` es 2, porque la columna identity sigue siendo una secuencia y perdió el 1 con el rollback. Conserva ambos: la identity como clave primaria y el número sin huecos como campo de negocio.

El precio es la concurrencia. Solo una transacción a la vez puede retener la fila contador. En nuestra prueba, una sesión la retuvo durante cinco segundos, y una segunda sesión que empezó alrededor de un segundo después tuvo su `UPDATE` esperando hasta que la primera hizo commit:

```text
 last_no
---------
       3
(1 row)

UPDATE 1
Time: 3943.462 ms (00:03.943)
```

Así que mantén estas transacciones cortas: toma el número al final, justo antes del `COMMIT`, y no llames a otros servicios mientras lo retienes. Una fila contador por año (o por serie, por tenant) también reparte la espera. En `REPEATABLE READ` o `SERIALIZABLE`, la segunda transacción igualmente espera, pero cuando la primera hace commit falla con `could not serialize access due to concurrent update`, y tu código tiene que reintentarla.

## ¿serial o identity?

Para tablas nuevas, usa `bigint GENERATED ALWAYS AS IDENTITY`. Es la sintaxis del estándar SQL, la columna y su secuencia quedan ligadas, y `GENERATED ALWAYS` rechaza IDs explícitos accidentales:

```text
ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.
```

La página "Don't Do This" de la wiki de la comunidad de PostgreSQL dice lo mismo: "For new applications, identity columns should be used instead" (en aplicaciones nuevas se deberían usar columnas identity en su lugar) de `serial`. Todo lo que dice este artículo sobre huecos e importaciones se aplica a ambas; solo cambia la protección contra importaciones y, como vimos arriba, `COPY` la ignora de todos modos.

## Ensaya la importación en una rama

El bug de importación es fácil de corregir y fácil de pasar por alto, porque solo aparece en el primer insert después de que llegan los datos. Prueba ese insert antes de que lo hagan tus usuarios. En Relyt One, una rama es una nueva línea de historial que parte de su rama padre en un punto en el tiempo, comparte sus páginas con copy-on-write y tiene su propio cómputo. Carga tu exportación en una rama, ejecuta la consulta que corrige todas las secuencias, inserta una fila en cada tabla y revisa `pg_sequences` en busca de algo cercano a su límite. Las escrituras en una rama no tocan a su padre, así que las secuencias de la rama avanzan por su cuenta; si algo sale mal, elimina la rama y empieza de nuevo.

Relyt One ejecuta PostgreSQL 17, y el SQL de este artículo no necesita extensiones. Las líneas `\c` y `\gexec` son comandos de `psql`, y la prueba de caída necesita un servidor Postgres que puedas detener tú mismo, así que ejecuta esa en local.

[Empieza gratis](/auth/signin?ref=blog-postgres-sequence-gaps-duplicate-key)
