PostgreSQL no puede usar ON CONFLICT DO UPDATE como una operación general de «coincidir con cualquiera de las claves únicas». Elige una clave que defina la identidad o comprueba explícitamente ambas claves en una transacción y rechaza las entradas que identifiquen dos filas distintas.

Este tutorial ayuda a un desarrollador que trabaja en Terminal en un Mac a implementar esa segunda política con node-postgres: insertar cuando ninguna clave coincide, actualizar cuando coincide exactamente un artículo y devolver un conflicto explícito cuando las claves apuntan a artículos distintos. Todas las filas son sintéticas.

Los ejemplos de SQL están dirigidos a PostgreSQL 18, la versión de la documentación actual consultada. La implementación usa una tabla ordinaria, no particionada, con dos restricciones únicas inmediatas. Comprueba la versión principal de tu servidor antes de adaptar ejemplos de otro manual de versión. Documentación de INSERT de PostgreSQL 18

Por qué dos restricciones únicas no pueden convertirse en un único destino de conflicto

Supongamos que un artículo tiene una referencia externa y un slug. Cada uno debe ser único de forma independiente. Empieza con esta configuración completa en una base de datos de demostración vacía; guárdala como setup.sql.

CREATE TABLE articles (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    external_key text NOT NULL,
    slug text NOT NULL,
    title text NOT NULL,
    CONSTRAINT articles_external_key_key UNIQUE (external_key),
    CONSTRAINT articles_slug_key UNIQUE (slug)
);

INSERT INTO articles (external_key, slug, title)
VALUES
    ('feed-101', 'blue-notes', 'Blue notes'),
    ('feed-202', 'green-notes', 'Green notes');

Estas declaraciones hacen cumplir dos reglas separadas. Reemplazarlas por UNIQUE (external_key, slug) cambiaría el modelo: solo el par tendría que ser único, lo que permitiría repetir claves externas y slugs en distintas combinaciones. Mantener ambas columnas como NOT NULL también evita introducir en este ejemplo el comportamiento de las claves que admiten valores nulos. Restricciones de PostgreSQL

Para DO UPDATE, PostgreSQL exige un destino de conflicto. Una lista de columnas describe las columnas de un índice único coincidente; no significa «cualquiera de las columnas». Por consiguiente, ON CONFLICT (external_key, slug) no puede inferir un árbitro a partir de las dos restricciones separadas anteriores. Nombrar una restricción selecciona esa restricción, y la sintaxis ofrece una cláusula ON CONFLICT, no una cadena de manejadores alternativos. INSERT de PostgreSQL: cláusula ON CONFLICT

El problema de fondo es decidir qué artículo existente representa la fila entrante. Considera los valores entrantes feed-101 y green-notes: la clave externa pertenece al artículo azul, mientras que el slug pertenece al artículo verde. Actualizar cualquiera de las filas con ambas claves entrantes entraría en conflicto con la otra fila. Elegir la primera coincidencia ocultaría una decisión pendiente sobre la entrada.

Decide qué debe ocurrir con cada combinación de coincidencias

Usa la siguiente tabla de decisiones antes de escribir la consulta. Es una política propuesta para la aplicación, derivada de este esquema, no un comportamiento que PostgreSQL proporcione automáticamente.

Clave externa entrante Slug entrante Filas coincidentes distintas Acción en este tutorial
Sin uso Sin uso 0 Insertar un artículo
Coincide con un artículo Sin uso 1 Actualizar ese artículo, incluido su slug
Sin uso Coincide con un artículo 1 Actualizar ese artículo, incluida su clave externa
Coincide con el artículo A Coincide con el artículo A 1 Actualizar el artículo A
Coincide con el artículo A Coincide con el artículo B 2 Rechazar la entrada; conservar ambos artículos

Los casos con una coincidencia permiten deliberadamente que cualquiera de las claves identifique un artículo y que la otra clave cambie. Confirma que este sea el comportamiento que necesita tu importación. Si una clave externa nunca debe cambiar, la coincidencia solo por slug debería convertirse en un conflicto de la aplicación.

Si tu regla real es más sencilla —«la clave externa siempre determina el artículo»—, usa un upsert normal con destino explícito. Esta instrucción completa se ejecuta después de la configuración anterior:

INSERT INTO articles (external_key, slug, title)
VALUES ('feed-101', 'blue-notes', 'Revised blue notes')
ON CONFLICT (external_key) DO UPDATE
SET slug = EXCLUDED.slug,
    title = EXCLUDED.title
RETURNING id, external_key, slug, title;

Aquí, EXCLUDED proporciona los valores propuestos. La otra restricción única sigue aplicándose: si se cambia el slug propuesto a green-notes, se produce un conflicto de unicidad independiente en lugar de cambiar al otro artículo. La garantía atómica de inserción o actualización del upsert con destino explícito depende de que no se produzcan errores independientes. Documentación de INSERT de PostgreSQL

Mantén estable la decisión sobre cualquiera de las claves durante escrituras concurrentes

Una consulta de búsqueda seguida de una inserción deja un intervalo entre la decisión y la escritura. Otra transacción podría insertar una clave coincidente durante ese intervalo. Añadir FOR UPDATE a la consulta bloquea las filas recuperadas; si no coincide ninguna fila, no hay ninguna fila recuperada que bloquear. Por eso, el bloqueo de filas por sí solo no basta para esta ruta concreta en la que no hay coincidencias. Bloqueo explícito de PostgreSQL

Para una importación pequeña o una operación ocasional desde el escritorio, una solución sencilla es adquirir un bloqueo de tabla SHARE ROW EXCLUSIVE antes de comprobar cualquiera de las claves. Este modo entra en conflicto con el bloqueo ROW EXCLUSIVE que adquieren las inserciones, actualizaciones y eliminaciones ordinarias, y también con otra instancia de sí mismo. Las lecturas ordinarias siguen siendo compatibles. Modos de bloqueo de tablas de PostgreSQL

La secuencia resultante es: iniciar la transacción, adquirir el bloqueo de tabla, buscar las filas coincidentes distintas, aplicar la tabla de decisiones y confirmar la transacción. El bloqueo explícito espera a que se liberen los bloqueos incompatibles y se mantiene hasta que termina la transacción. LOCK de PostgreSQL

Usa explícitamente READ COMMITTED en este caso. La consulta ve los datos confirmados antes de que comience, incluidas las escrituras que se hayan completado mientras esperaba la adquisición del bloqueo. Combinar ese comportamiento de la instantánea con el bloqueo de tabla adquirido proporciona una base estable para decidir y escribir. Aislamiento de transacciones de PostgreSQL

Este bloqueo es amplio: también espera a que terminen las escrituras de artículos no relacionados. Mantén breve la transacción y no hagas preguntas interactivas ni trabajo externo entre el bloqueo y la confirmación. Si las importaciones concurrentes son frecuentes, reconsidera si una clave canónica puede definir la identidad antes de adoptar este diseño.

Implementa la transacción con node-postgres

Necesitas una base de datos de demostración PostgreSQL 18 en ejecución, el comando psql y una versión de Node.js compatible con el paquete node-postgres instalado. En Terminal, instala el paquete y carga el archivo de configuración. El primer comando crea metadatos del paquete si el directorio de demostración aún no los tiene. Instalación y compatibilidad de node-postgres

npm init -y
npm install pg
psql -v ON_ERROR_STOP=1 -f setup.sql

Estos comandos suponen que la configuración habitual de conexión local ya selecciona la base de datos de demostración. El programa usa new Pool(), que lee las mismas variables de entorno de conexión de PostgreSQL que psql; establece PGDATABASE con el nombre de tu base de datos de demostración si es necesario. Conexión con node-postgres

Guarda este programa completo como upsert-articles.mjs. Supone que la tabla de configuración no tiene disparadores ni reglas adicionales que cambien el comportamiento de búsqueda o actualización. Los valores se pasan mediante parámetros posicionales separados del texto SQL. Consultas con node-postgres

import pg from 'pg';
const { Pool } = pg;
const pool = new Pool();

async function upsertArticle(input) {
  const client = await pool.connect();
  let discardClient = false;

  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
    await client.query(
      'LOCK TABLE articles IN SHARE ROW EXCLUSIVE MODE'
    );

    const matches = await client.query(
      `SELECT id FROM articles
       WHERE external_key = $1 OR slug = $2
       ORDER BY id`,
      [input.externalKey, input.slug]
    );

    if (matches.rows.length > 1) {
      const error = new Error(
        'Incoming keys identify two different articles'
      );
      error.code = 'ARTICLE_KEY_CONFLICT';
      throw error;
    }

    const values = [input.externalKey, input.slug, input.title];
    const result = matches.rows.length === 0
      ? await client.query(
          `INSERT INTO articles (external_key, slug, title)
           VALUES ($1, $2, $3)
           RETURNING id, external_key, slug, title`,
          values
        )
      : await client.query(
          `UPDATE articles
           SET external_key = $1, slug = $2, title = $3
           WHERE id = $4
           RETURNING id, external_key, slug, title`,
          [...values, matches.rows[0].id]
        );

    if (result.rows.length !== 1) {
      throw new Error('Expected exactly one article');
    }

    await client.query('COMMIT');
    return result.rows[0];
  } catch (error) {
    try {
      await client.query('ROLLBACK');
    } catch {
      discardClient = true;
    }
    throw error;
  } finally {
    client.release(discardClient);
  }
}

try {
  console.log(await upsertArticle({
    externalKey: 'feed-101',
    slug: 'blue-notes',
    title: 'Revised blue notes'
  }));
} finally {
  await pool.end();
}

Todas las instrucciones de la transacción usan el mismo cliente obtenido del grupo de conexiones, tal como requiere node-postgres. Enviar estas instrucciones mediante llamadas separadas a pool.query() no preservaría ese requisito de conexión. Transacciones con node-postgres

El bloque finally siempre libera el cliente obtenido. Si también falla la reversión, release(true) descarta la conexión. El pool.end() externo cierra el grupo de conexiones cuando termina el script de escritorio. API de grupos de conexiones de node-postgres

Ejecuta el programa con node upsert-articles.mjs. El resultado esperado es el artículo azul con el título revisado y las claves sin cambios. Se comprobó la sintaxis de JavaScript con Node.js; los ejemplos de base de datos se contrastaron con la documentación, pero no se ejecutaron aquí en un servidor PostgreSQL.

Comprueba cada rama y gestiona los errores explícitamente

Con las filas de configuración, cambia el objeto que se pasa a upsertArticle() para probar los siguientes casos. Ejecuta cada caso con el estado original de la configuración o ten en cuenta los cambios de los casos anteriores.

externalKey slug Resultado esperado
feed-303 amber-notes Insertar un artículo nuevo
feed-101 blue-revised Actualizar el artículo azul y reemplazar su slug
feed-303 blue-notes Actualizar el artículo azul y reemplazar su clave externa
feed-101 blue-notes Actualizar el título del artículo azul
feed-101 green-notes Lanzar ARTICLE_KEY_CONFLICT; no cambiar ninguno de los artículos

La consulta devuelve filas, así que un artículo que coincide con ambas condiciones se cuenta una sola vez. Con estas dos claves únicas independientes que no admiten valores nulos, pueden coincidir como máximo dos filas distintas. El código nunca resuelve un resultado de dos filas usando LIMIT 1, eliminando un artículo o ignorando la entrada silenciosamente.

ARTICLE_KEY_CONFLICT es un código definido por la aplicación. 23505 de PostgreSQL significa unique_violation; es una condición distinta. Si aparece esta última condición después de adaptar el ejemplo, examina la restricción vulnerada y cualquier comportamiento añadido a la tabla, en lugar de tratar todos los duplicados como si tuvieran el mismo resultado. PostgreSQL recomienda comprobar los códigos de error en vez de analizar el texto del mensaje. Códigos de error de PostgreSQL

La adquisición del bloqueo puede esperar. Si prefieres un error inmediato para esta tarea de escritorio, sustituye la instrucción de bloqueo por LOCK TABLE articles IN SHARE ROW EXCLUSIVE MODE NOWAIT. PostgreSQL genera un error si no puede adquirir el bloqueo de inmediato, y el programa sigue la ruta de reversión. LOCK de PostgreSQL

¿Simplificarían la tarea DO NOTHING o MERGE?

El ON CONFLICT DO NOTHING sin destino explícito puede omitir una inserción que entre en conflicto con cualquier restricción o índice único utilizable. Elígelo cuando omitir sea el resultado deseado; no implementa la política de actualización desarrollada aquí. Documentación de INSERT de PostgreSQL

MERGE se introdujo en PostgreSQL 15. Aunque permite una condición de unión que implique cualquiera de las claves, esa unión aún necesita una política para el caso en que una fila de origen coincida con dos artículos de destino distintos. Sus acciones siguen sujetas a las restricciones habituales, y su comportamiento ante escrituras concurrentes no es intercambiable con el de un upsert con destino explícito. Notas de la versión 15 de PostgreSQL, MERGE de PostgreSQL

Para la tarea de coincidir con cualquiera de las claves, haz que el número de coincidencias determine explícitamente la decisión: cero inserta, una actualiza y dos detienen la operación con un conflicto claro. Mantén las restricciones únicas y elige una estrategia de concurrencia que preserve esa decisión hasta completar la escritura. La implementación con bloqueo de tabla ofrece un punto de partida concreto cuando es aceptable serializar las escrituras de artículos.

Fuentes