---
name: pg_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_query(dsn: str, sql: str, params: list = None, max_rows: int = 10000) -> dict"
description: "Ejecuta un SELECT contra PostgreSQL via psycopg2 y devuelve las filas como list[dict] sin lanzar. Abre la conexion con el DSN, marca la transaccion read-only (SET TRANSACTION READ ONLY) y usa RealDictCursor para que cada fila sea un dict columna->valor. Devuelve {status:'ok', columns, rows, row_count, truncated} en exito y {status:'error', error} en fallo (estilo duckdb_query_readonly). Usa parametros posicionales con el marcador %s. Trunca a max_rows para proteger memoria. Normaliza valores no JSON-serializables: date/datetime/time a isoformat(), Decimal a float, bytes/memoryview a base64, UUID a str. Cierra la conexion siempre en try/finally. Espejo de duckdb_query_readonly para Postgres. Depende de psycopg2 (2.9.x en python/.venv)."
tags: [postgres, postgresql, sql, query, readonly, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: [base64, datetime, decimal, uuid, psycopg2]
params:
  - name: dsn
    desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname. Un DSN invalido o servidor inalcanzable devuelve {status:'error'} sin lanzar."
  - name: sql
    desc: "Sentencia SQL a ejecutar (pensada para SELECT). Usa el marcador %s para parametros posicionales (estilo psycopg2)."
  - name: params
    desc: "Lista de parametros posicionales para el SQL en orden. None (default) significa sin parametros. Pasar los valores aqui en vez de interpolarlos en el SQL evita inyeccion."
  - name: max_rows
    desc: "Numero maximo de filas a materializar en memoria (default 10000). Si la query produce mas, el resultado se trunca y truncated queda en True."
output: "dict. En exito: {status:'ok', columns:[str,...], rows:[{col:val,...},...], row_count:int, truncated:bool}; las filas son dicts (RealDictCursor). En error (sin lanzar): {status:'error', error:str}. Los valores estan normalizados a tipos JSON-serializables."
tested: true
tests: ["test_skip_sin_pg_test_dsn", "test_normaliza_tipos_no_serializables", "test_select_con_parametros_posicionales", "test_trunca_a_max_rows", "test_dsn_invalido_devuelve_status_error"]
test_file_path: "python/functions/infra/pg_query_test.py"
file_path: "python/functions/infra/pg_query.py"
---

## Ejemplo

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

dsn = "postgresql://user:pass@localhost:5433/trends"

# SELECT con parametro posicional (nunca interpolar el valor en el SQL).
res = pg_query(
    dsn,
    "SELECT product, price FROM prices WHERE source = %s ORDER BY price DESC",
    params=["amazon"],
    max_rows=100,
)
print(res["status"])      # ok
print(res["columns"])     # ['product', 'price']
print(res["rows"][0])     # {'product': 'Widget X', 'price': 19.99}
print(res["truncated"])   # False
```

## Cuando usarla

Usala cuando necesites leer datos de Postgres y pasarlos a otro paso de una
composicion como dict serializable: inspeccionar una tabla, validar el resultado de
un pipeline de ingesta, alimentar un dashboard o report, o consultar tablas
materializadas. Es el espejo de `duckdb_query_readonly` para Postgres. Para escribir
usa `pg_insert_rows`, `pg_upsert` o `pg_apply_sql`.

## Gotchas

- Lectura real contra un servidor (impura). La transaccion se marca read-only con
  `set_session(readonly=True)` y nunca se hace commit (rollback al final): cualquier
  `INSERT`/`UPDATE`/`DELETE` en el SQL falla a nivel de servidor y vuelve como
  `{status:'error', ...}`. NO es un sandbox de filesystem — read-only protege la
  base, no impide leer datos sensibles si el SQL viene de un cliente no confiable.
- Inyeccion SQL: los **valores** van siempre por `params` con el marcador `%s`,
  nunca interpolados en el string del SQL. Esta funcion NO valida ni parametriza
  identificadores (nombres de tabla/columna): si necesitas un nombre de tabla
  dinamico, validalo tu antes con `^[A-Za-z_][A-Za-z0-9_]*$`.
- `max_rows` protege la memoria: una query que devuelve millones de filas se trunca
  a `max_rows` y marca `truncated=True`. Para todas las filas, pagina con
  LIMIT/OFFSET o sube `max_rows` conscientemente.
- Valores no JSON-serializables se normalizan en la salida: date/datetime/time a
  `isoformat()`, Decimal a float (posible perdida de precision frente al decimal
  exacto), bytes/memoryview a base64 y UUID a str.
- Conexion nueva por llamada (sin pool). Para muchas consultas pequenas en bucle,
  reusa una conexion fuera de esta funcion o agrupa el trabajo en una sola query.
- Nunca lanza: DSN invalido, servidor caido, SQL malformado o falta de psycopg2
  vuelven como `{status:'error', error:str}`.
