---
name: pg_upsert
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_upsert(dsn: str, table: str, rows: list[dict], key_cols: list[str], update_cols: list[str] = None) -> dict"
description: "UPSERT idempotente en lote en una tabla PostgreSQL con ownership selectivo de columnas. Construye INSERT INTO <table> (cols) VALUES %s ON CONFLICT (key_cols) DO UPDATE SET col = EXCLUDED.col, ... (o DO NOTHING) y lo ejecuta con psycopg2.extras.execute_values. update_cols=None actualiza todas menos key_cols; update_cols=[] hace DO NOTHING; lista explicita = ownership selectivo (las no listadas conservan su valor). Distingue insert vs update via el pseudo-columna xmax (RETURNING (xmax = 0) AS inserted). Valida que table y columnas casen ^[A-Za-z_][A-Za-z0-9_]*$ antes de interpolarlas; los valores van por placeholders. Commit al exito, rollback al fallo, cierre en try/finally. Devuelve {status:'ok', inserted, updated} o {status:'error', error} sin lanzar. Espejo de duckdb_upsert para Postgres. key_cols deben tener PRIMARY KEY o UNIQUE. Depende de psycopg2 (2.9.x en python/.venv)."
tags: [postgres, postgresql, sql, upsert, idempotent, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [re, psycopg2]
params:
  - name: dsn
    desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
  - name: table
    desc: "Nombre de la tabla destino. Validado como identificador SQL [A-Za-z_][A-Za-z0-9_]*; un nombre raro devuelve {status:'error'}. La tabla debe existir y key_cols debe tener PRIMARY KEY o UNIQUE."
  - name: rows
    desc: "Lista de dicts, un dict por fila (clave = nombre de columna). El esquema de insercion lo fija la PRIMERA fila; todas deben tener exactamente las mismas claves o se devuelve error. Lista vacia -> {status:'ok', inserted:0, updated:0}."
  - name: key_cols
    desc: "Columnas de la clave de conflicto (no vacia). Deben existir como PRIMARY KEY o UNIQUE en la tabla y estar presentes en las claves de cada fila."
  - name: update_cols
    desc: "Columnas a actualizar en conflicto. None (default) = todas menos key_cols. [] = DO NOTHING (inserta nuevas, no toca existentes). Lista = DO UPDATE SET solo esas (ownership selectivo: las no listadas conservan su valor previo)."
output: "dict. En exito: {status:'ok', inserted:int, updated:int} (inserted = filas con xmax=0 en RETURNING, updated = filas en conflicto actualizadas). Con DO NOTHING las filas en conflicto no se devuelven por RETURNING y no cuentan en ninguno. En error (sin lanzar): {status:'error', error:str}."
tested: true
tests: ["test_skip_sin_pg_test_dsn", "test_identificador_invalido_devuelve_status_error", "test_inserta_filas_nuevas_cuenta_inserted", "test_conflicto_actualiza_y_cuenta_updated", "test_ownership_selectivo_no_pisa_columna_excluida", "test_do_nothing_no_actualiza"]
test_file_path: "python/functions/infra/pg_upsert_test.py"
file_path: "python/functions/infra/pg_upsert.py"
---

## Ejemplo

```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from infra.pg_upsert import pg_upsert

dsn = "postgresql://user:pass@localhost:5433/trends"
# La tabla leads(email PRIMARY KEY, name TEXT, score INT) ya existe.

# Re-ingest 1: inserta el lead.
print(pg_upsert(
    dsn, "leads",
    [{"email": "ana@x.com", "name": "Ana", "score": 0}],
    key_cols=["email"],
))
# {'status': 'ok', 'inserted': 1, 'updated': 0}

# Re-ingest 2: el feed trae name actualizado y score=0 (default del feed),
# pero solo autorizamos actualizar 'name'. 'score' lo posee la DB y NO se pisa.
print(pg_upsert(
    dsn, "leads",
    [{"email": "ana@x.com", "name": "Ana Lopez", "score": 0}],
    key_cols=["email"],
    update_cols=["name"],
))
# {'status': 'ok', 'inserted': 0, 'updated': 1}
```

## Cuando usarla

Usala cuando un re-ingest periodico no debe pisar campos que ya posee la DB: pasa
`update_cols` SIN esos campos (ownership selectivo). Tipico en pipelines de ingesta
idempotente (catalogo, leads, precios competencia, entidades OSINT) donde una fila
se reinserta y ciertas columnas se enriquecieron despues (score calculado, anotacion
manual, flag derivado) y deben sobrevivir al refresco. `update_cols=None` para un
upsert "todo" clasico, `update_cols=[]` para insertar solo filas nuevas. Es el espejo
de `duckdb_upsert` para Postgres. Para append-only puro usa `pg_insert_rows`.

## Gotchas

- Escritura real en disco (impura). `ON CONFLICT (key_cols)` solo funciona si esas
  columnas tienen **PRIMARY KEY o UNIQUE** en la tabla; sin esa restriccion Postgres
  lanza error y vuelve como `{status:'error', ...}`. La tabla debe existir de antemano
  (la funcion NO la crea — usa `pg_create_table_from_rows`).
- **Fiabilidad de inserted/updated**: el conteo usa el pseudo-columna del sistema
  `xmax` (`RETURNING (xmax = 0)`). Es la tecnica estandar y fiable en el caso normal
  (single-writer, sin triggers raros): xmax = 0 = INSERT puro, xmax != 0 = UPDATE por
  conflicto. Caveats conocidos: (1) con `update_cols=[]` (DO NOTHING) las filas en
  conflicto NO se devuelven por RETURNING, asi que ni cuentan como insert ni como
  update — solo se reportan las filas nuevas en `inserted`; (2) si la tabla tiene
  BEFORE INSERT/UPDATE triggers, REPLICA IDENTITY o subtransacciones que tocan la
  fila, el valor de xmax puede no ser 0 en un insert real y desviar el conteo.
- **Inyeccion SQL**: `table` y los nombres de columna se validan contra
  `^[A-Za-z_][A-Za-z0-9_]*$` antes de interpolarlos (no se pueden parametrizar
  identificadores). Un nombre con espacios, comillas, puntos o vacio devuelve
  `{status:'error'}`. Los valores de las filas siempre van por los placeholders de
  `execute_values`.
- **Esquema fijo por la primera fila**: el conjunto de columnas de insercion lo
  determina `rows[0]`. Todas las filas deben tener exactamente las mismas claves; si
  una difiere, se devuelve error (no se hace insercion parcial).
- **Single-statement por lote**: todo el lote va en un solo `INSERT ... VALUES %s`
  dentro de una transaccion. Si una fila viola una constraint (FK, NOT NULL en una
  columna ausente), Postgres aborta el lote entero y se hace rollback.
- Nunca lanza: DSN invalido, tabla sin UNIQUE, tipo invalido o falta de psycopg2
  vuelven como `{status:'error', error:str}`.
