---
name: summarize_table_pg
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def summarize_table_pg(dsn: str, table: str, schema: str = \"public\", high_card_ratio: float = 0.9) -> dict"
description: "Adaptador PostgreSQL del perfilado base del grupo eda: espejo de summarize_table_duckdb. Perfila una tabla PostgreSQL con SQL push-down (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) sin traer filas a RAM, y devuelve EXACTAMENTE el mismo esqueleto TableProfile (mismas claves) para que el resto del grupo eda lo consuma igual con fuente PostgreSQL. dict-no-throw."
tags: [eda, postgres, postgresql, profiling, datascience, exploratory-data-analysis, table-profile]
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 (se propaga el error de pg_query)."
  - name: table
    desc: "Nombre de la tabla a perfilar. Se valida contra ^[A-Za-z_][A-Za-z0-9_]*$ y se cita en el SQL (los identificadores no son parametrizables en el cuerpo del SELECT)."
  - name: schema
    desc: "Schema PostgreSQL donde vive la tabla (default 'public'). Se valida con el mismo patron y se cita."
  - name: high_card_ratio
    desc: "Umbral de unicidad (unique_pct, 0-1) a partir del cual una columna categorical recibe el flag high_cardinality. Default 0.9."
output: "dict dict-no-throw. En exito {status:'ok', profile: TableProfile} con source='postgres' y el MISMO shape que summarize_table_duckdb (n_rows/n_cols, type_breakdown, constant_cols, all_null_cols, null_cell_pct y columns[] de ColumnProfile con name/physical_type/inferred_type/semantic_type/count/null_count/null_pct/distinct_count/unique_pct/flags y sub-dict numeric con min,max,mean,std,p25,p50,p75 y el resto en None). En error {status:'error', error:str}. Claves estadisticas finas (skew, kurtosis, histograma, percentiles finos, moda, outliers, correlaciones, key_candidates, quality_score) quedan en None/[] para que otras funciones del grupo eda las completen."
uses_functions: [pg_query_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests: ["test_shape_y_metadatos_tabla", "test_column_profile_shape", "test_null_pct_total", "test_distinct_no_excede_filas", "test_type_breakdown", "test_tabla_invalida_devuelve_error", "test_schema_invalido_devuelve_error", "test_tabla_inexistente_devuelve_error", "test_error_de_lectura_pg_se_propaga"]
test_file_path: "python/functions/datascience/summarize_table_pg_test.py"
file_path: "python/functions/datascience/summarize_table_pg.py"
---

## Ejemplo

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

# Perfila la tabla `trends` del PostgreSQL del proyecto captacion_clientes
# (la misma base que alimenta Metabase).
res = summarize_table_pg(
    dsn="postgresql://captacion:secret@localhost:5433/trends",
    table="amazon_bestsellers",
    schema="public",
    high_card_ratio=0.9,
)

if res["status"] == "ok":
    p = res["profile"]
    print(f"{p['table']}: {p['n_rows']} filas x {p['n_cols']} cols  (source={p['source']})")
    print("type_breakdown:", p["type_breakdown"])
    for col in p["columns"]:
        print(col["name"], col["inferred_type"], "nulls=", col["null_pct"], col["flags"])
else:
    print("error:", res["error"])
```

## Cuando usarla

- Cuando hagas EDA de una tabla PostgreSQL que no conoces y necesites el esqueleto barato de su perfil (tipos inferidos, nulos, cardinalidad, flags) **antes** de gastar en estadistica fina. Tipico: las bases PostgreSQL conectadas a Metabase (trends, captacion_clientes, etc.).
- Como adaptador PostgreSQL del grupo `eda`: produce el mismo TableProfile que `summarize_table_duckdb`, de modo que `profile_table` y el resto del grupo funcionan igual cambiando solo la fuente.
- Cuando quieras perfilar tablas grandes sin traer filas a RAM: todo se calcula con agregados (count, count(DISTINCT), min/max/avg/stddev_samp, percentile_cont) que hacen push-down en el motor de PostgreSQL.

## Gotchas

- **Impura**: lee de un servidor PostgreSQL via `pg_query` (transaccion read-only, nunca escribe). Requiere `psycopg2` (ya en `python/.venv`) y un DSN valido; un servidor inalcanzable devuelve `{status:'error'}` sin lanzar.
- **`distinct_count` exacto solo hasta 200000 filas**: para `n_rows <= 200000` se calcula `count(DISTINCT col)` EXACTO en la query agregada por columna. Por encima de ese umbral NO se estima (PostgreSQL no trae HyperLogLog de serie sin extension) y `distinct_count` se capa de forma conservadora a `min(count_no_nulo, n_rows)`. En ambos casos `unique_pct = min(distinct_count / n_rows, 1.0)`, asi que nunca excede 1.0. Por encima de 200k filas los flags `possible_id` / `high_cardinality` derivan de esa cota conservadora, no de un distinct real.
- **El shape es identico a `summarize_table_duckdb`** (mismas claves de TableProfile y ColumnProfile, mismo sub-dict `numeric`) para que `profile_table` y el grupo `eda` lo consuman sin distinguir la fuente. `source` es `"postgres"` (vs `"duckdb"`). NO calcula skew, kurtosis, histograma, percentiles finos (p1/p5/p95/p99), moda, outliers, correlaciones, key_candidates ni quality_score: esas claves quedan en `None`/`[]` para otras funciones del grupo. El sub-dict `numeric` solo trae min, max, mean, std, p25, p50, p75 (estos tres ultimos via `percentile_cont WITHIN GROUP`).
- **Identificadores (schema/tabla/columna) se interpolan citados, no son parametrizables**: por eso `table` y `schema` se validan contra `^[A-Za-z_][A-Za-z0-9_]*$` antes de citarlos con comillas dobles. Un nombre invalido (con `;`, espacios, etc.) devuelve `{status:'error'}` sin tocar la base. Los **valores** (schema/table de la query a `information_schema`) si van por parametros posicionales `%s`.
- **`count` del ColumnProfile es el no-nulo** (`count(col)`); `null_count = n_rows - count`. Una tabla con 0 filas devuelve perfiles con `null_pct=0.0` y `distinct_count=0`.

## Notas

Contrato compartido por todo el grupo `eda` (identico a `summarize_table_duckdb`,
mantener estable):

```text
TableProfile = {
  table, source, profiled_at, n_rows, n_cols, size_bytes, duplicate_rows,
  duplicate_pct, constant_cols:[str], all_null_cols:[str], null_cell_pct,
  type_breakdown:{numeric, categorical, datetime, text, boolean},
  columns:[ColumnProfile], correlations, key_candidates:[str], quality_score,
  llm, models
}
ColumnProfile = {
  name, physical_type, inferred_type, semantic_type, count, n_rows, null_count,
  null_pct, empty_count, empty_pct, distinct_count, unique_pct, flags:[str],
  quality_score, numeric:<sub>|None, categorical:<sub>|None, datetime:<sub>|None
}
numeric_sub = {
  min, max, mean, median, mode, std, variance, cv, p1, p5, p25, p50, p75, p95,
  p99, iqr, skew, kurtosis, n_outliers, outlier_pct, zero_pct, negative_pct,
  distribution_type, histogram
}
```

Mapeo de `data_type` (information_schema) PostgreSQL a `inferred_type`:
smallint/integer/bigint/numeric/decimal/real/double precision/serial* -> numeric;
date/time*/timestamp* -> datetime; boolean -> boolean; text/varchar/character* ->
categorical si `distinct_count <= 50` o `distinct_count/n_rows < 0.5`, si no text;
el resto (json, jsonb, uuid, array, bytea, ...) -> text.

Flags por columna: `constant` (distinct_count<=1), `possible_id` (unique_pct>=0.99
y null_pct==0), `high_cardinality` (categorical con unique_pct>=high_card_ratio),
`mostly_null` (null_pct>0.5).
