SQL en Python
Acceso a bases de datos relacionales desde Python: drivers, ORM y motores analíticos
Sobre SQL en Python
El acceso a bases de datos relacionales desde Python no es un único problema sino un espectro de capas que conviene tener interiorizado antes de elegir una herramienta. De más bajo a más alto nivel:
- Drivers nativos (
psycopg,asyncpg,pyodbc,mysqlclient,sqlite3). Hablan el protocolo binario de la base. Devuelven tuplas o filas planas. Máximo control, mínimo azúcar. - SQLAlchemy: el estándar de facto en Python. Aporta connection pooling, abstracción de dialecto, transacciones explícitas y dos niveles bien diferenciados (Core como constructor de expresiones SQL. ORM como mapeo a clases Python).
- Motores analíticos en proceso (
duckdb,polars.sql) y abstracciones portables (ibis). Pensados para análisis sobre datos en memoria o en ficheros (Parquet, CSV, Arrow), no para servir tráfico transaccional. pandas.read_sql: atajo conveniente que apoyado en SQLAlchemy convierte un resultado enDataFrame. Cómodo para análisis ad hoc. Raramente lo que quieres en un servicio.
Dos principios que conviene tener claros:
- Parametriza siempre. El formateo de cadenas (
f"SELECT ... WHERE id = {user_id}") es la fuente número uno de inyecciones SQL. Todas las APIs serias aceptan parámetros (?,%s,:name). Úsalos sin excepción. - Cierra lo que abres. Las conexiones y los cursores son recursos del sistema operativo. El patrón canónico es
with engine.connect() as conn:(oasync with). Fugas de conexión saturan el pool y producen timeouts aparentemente aleatorios en producción.
Esta página cataloga las piezas centrales del stack. El orden refleja la jerarquía conceptual: primero la pieza estructural (SQLAlchemy y read_sql), después los motores analíticos (DuckDB, Polars, Ibis), y finalmente los drivers nativos cuando bajar a esa capa está justificado.
SQLAlchemy
SQLAlchemy es la pieza estructural del acceso a bases relacionales en Python: gestiona el connection pool, abstrae las diferencias entre dialectos (PostgreSQL, MySQL, SQLite, SQL Server, Oracle…), y ofrece dos capas que conviene distinguir desde el primer día:
- Core: un constructor de expresiones SQL tipado. Escribes consultas componibles en Python que se compilan al dialecto del motor. Es la capa “sin ORM”.
- ORM: mapeo declarativo de clases Python a tablas, con sesión, identity map y unit of work. Pensado para lógica de dominio compleja.
Desde SQLAlchemy 2.0 (2023) la API se ha unificado en torno a select() y un estilo más estricto que premia la claridad sobre la magia. Cualquier código anterior a 2.0 que veas en internet probablemente esté desactualizado.
Cuándo usarlo
- Servicios y aplicaciones que persisten estado en una base relacional. Es la opción por defecto razonable.
- Cuando necesitas portabilidad real entre motores (mismo código contra SQLite en dev y Postgres en prod).
- Cuando quieres connection pooling gestionado: SQLAlchemy gestiona reciclado, check-out, ping y reconexión sin que tengas que pensarlo.
Cuándo NO usarlo
- Análisis ad hoc sobre ficheros locales (Parquet, CSV). Ahí DuckDB o Polars son drásticamente más simples.
- Cargas masivas de extracción analítica. Para mover decenas de millones de filas a un
DataFrame, prefiereconnectorxo el driver nativo conCOPY/fetchmany. - Pipelines puramente asíncronos de muy alto throughput sobre Postgres. SQLAlchemy soporta async (
AsyncEngine), peroasyncpgdirecto es más rápido y predecible cuando no necesitas las capas superiores.
Core vs ORM
La decisión no es ideológica sino pragmática:
- Core brilla cuando tu código piensa en términos de consultas (joins, agregaciones, upserts) y no en términos de objetos con identidad. Es la elección natural en pipelines de datos y reporting.
- ORM brilla cuando tu dominio tiene entidades con ciclo de vida (usuarios, pedidos, sesiones) y reglas de negocio que viven en clases Python. El precio es entender la unit of work y evitar el problema N+1.
En la práctica, una codebase madura usa ambas: ORM para escrituras de dominio, Core para queries analíticas dentro del mismo proceso.
Conceptos clave
- Engine vs Connection vs Session.
create_engine(url)crea una factoría con un pool, debe ser un único objeto a nivel de aplicación, no por petición.engine.connect()saca una conexión del pool. LaSessiondel ORM envuelve una conexión con semántica transaccional propia. - Transacciones. Desde 2.0,
engine.begin()es el patrón canónico: confirma al salir delwithsi no hubo excepción, rollback si la hubo.engine.connect()por sí solo no compromete cambios hasta que llamescommit()(modo commit as you go). - Parametrización.
text("SELECT ... WHERE id = :id")conparams={"id": ...}o expresiones tipadas conselect(Table).where(Table.c.id == ...). Nunca concatenes valores de usuario en la cadena SQL. - Dialecto. La URL (
postgresql+psycopg://,mysql+pymysql://,mssql+pyodbc://) determina el driver. SQLAlchemy traduce funciones, tipos y sintaxis específica de cada motor. - Async.
create_async_engine+AsyncSession. El código de modelo es el mismo. Los puntos de I/O usanawait.
Patrón mínimo
from sqlalchemy import create_engine, text
# Un único engine a nivel de aplicación
engine = create_engine(
"postgresql+psycopg://user:pass@localhost:5432/mydb",
pool_size=5,
pool_pre_ping=True, # detecta conexiones muertas tras idle
)
# Lectura con transacción implícita
with engine.connect() as conn:
rows = conn.execute(
text("SELECT id, email FROM users WHERE created_at > :since"),
{"since": "2026-01-01"},
).all()
# Escritura con transacción explícita
with engine.begin() as conn:
conn.execute(
text("UPDATE users SET active = :a WHERE id = :id"),
[{"a": False, "id": 1}, {"a": False, "id": 2}], # bulk
)Con ORM declarativo (2.0):
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy import select
class Base(DeclarativeBase): ...
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
active: Mapped[bool] = mapped_column(default=True)
with Session(engine) as session, session.begin():
user = session.scalar(select(User).where(User.email == "a@b.com"))
user.active = False # flush automático al commitTrampas habituales
- Crear engines por petición. El pool deja de tener sentido y se acumulan conexiones.
create_enginese llama una vez al iniciar la aplicación. - Olvidar
commit()en modo commit as you go.engine.connect()no confirma automáticamente. Si no llamasconn.commit()ni usasengine.begin(), los cambios se pierden al cerrar la conexión. Síntoma típico: “losINSERTfuncionan en tests pero no aparecen en la BD”. - Concatenación de strings en cláusulas dinámicas. Para nombres de columna o tabla parametrizables, usa
bindparam+ lista blanca explícita. Nunca interpolación. - Sesiones largas en ORM. La
Sessionmantiene el identity map en memoria. Sesiones de larga vida acumulan objetos y producen staleness. Patrón: sesión por unidad de trabajo (petición HTTP, job de cola), no por aplicación. - N+1 silencioso. Acceder a relaciones lazy dentro de un bucle dispara una consulta por iteración.
selectinload()ojoinedload()lo arreglan. Sin perfilado SQL, pasan desapercibidos. pool_pre_ping=Falsecon bases tras balanceador. Conexiones cortadas por el LB producen errores en el primer uso. Activarpool_pre_ping=Trueañade unSELECT 1barato pero evita errores espurios.
Enlaces
Relacionados en esta página
pandas.read_sql, se apoya en SQLAlchemy para la conexión.ibis, DSL portable que puede ejecutar contra SQLAlchemy como backend.psycopgyasyncpg, drivers Postgres que SQLAlchemy utiliza por debajo.
pandas.read_sql
pandas.read_sql (y sus primas read_sql_query, read_sql_table) es el atajo canónico para materializar el resultado de una consulta SQL como DataFrame. Internamente delega en SQLAlchemy (o, deprecado, en una conexión DB-API directa) y aplica conversión de tipos a NumPy/pandas.
Es la pieza más usada, y más abusada, del catálogo. Cómoda para análisis ad hoc en notebooks, problemática como pieza de un pipeline serio.
Cuándo usarlo
- Notebooks y análisis exploratorios donde el resultado cabe holgadamente en memoria (decenas o cientos de miles de filas).
- Reporting interno donde el flujo natural es SQL →
DataFrame→ gráfica. - Cuando ya tienes un
Enginede SQLAlchemy a mano y quieres el atajo en vez de iterar cursores.
Cuándo NO usarlo
- Cargas masivas. Para mover millones de filas,
connectorx.read_sqles órdenes de magnitud más rápido (lee directamente a Arrow), y los drivers nativos conCOPY(Postgres) ofetchmanypor bloques evitan picos de memoria. - Producción transaccional.
read_sqlestá pensado para análisis. Si tu lógica de negocio depende del resultado, usa SQLAlchemy directamente, más control sobre transacciones, tipos y errores. - Cuando vas a procesar con Polars o DuckDB. Pasar por
pandases overhead innecesario. Ambos leen SQL directamente.
Conceptos clave
- Acepta
con=comoEngine,Connectiono conexión DB-API. Con SQLAlchemy se obtiene parametrización segura. Con DB-API plano, el contrato es ambiguo y depende del driver. params=parametriza la consulta. Sintaxis depende del driver::namecon SQLAlchemy,%so?con DB-API. La regla práctica: usa siempre SQLAlchemy y:name.chunksize=devuelve un iterador deDataFrames. Útil para procesar resultados grandes sin agotar memoria, aunque el tipado por chunk puede variar y dar dolores de cabeza.dtype=(pandas ≥ 2.0) ydtype_backend="pyarrow"permiten respaldar elDataFramecon Arrow, lo que evita la conversión costosa NumPy ↔︎ object para strings y fechas.
Patrón mínimo
import pandas as pd
from sqlalchemy import create_engine
engine = create_engine("postgresql+psycopg://user:pass@host/db")
# Consulta parametrizada — NUNCA usar f-strings
df = pd.read_sql(
"SELECT id, email, created_at FROM users WHERE country = :c",
con=engine,
params={"c": "ES"},
parse_dates=["created_at"],
)
# Procesado por chunks para resultados grandes
for chunk in pd.read_sql(
"SELECT * FROM events WHERE ts > :since",
con=engine,
params={"since": "2026-01-01"},
chunksize=100_000,
):
process(chunk)Trampas habituales
- F-strings en la consulta. El antipatrón clásico,
f"... WHERE id = {user_input}"es inyección SQL. Usaparams=siempre. - Conversión de tipos imprevista. Columnas
BIGINTcon nulos se convierten afloat64. ColumnasTIMESTAMP WITH TIME ZONEpueden perder la zona o ganar la del sistema. Verificadf.dtypesdespués de cargar y consideradtype_backend="pyarrow". chunksizey tipos inconsistentes. Cada chunk infiere tipos por separado. Valores ausentes en chunks tempranos generan dtypes distintos a los de chunks tardíos. Fija dtypes condtype=cuando uses chunks.- Conexión vs Engine.
read_sql(..., con=conn)con unaConnectionabierta no la cierra. Con unEngine, pandas la saca y la devuelve al pool automáticamente. Pasar unEnginees lo seguro por defecto. - Memoria. El
DataFrameconobjectpara strings consume mucho más que el resultado original. Para más de unos millones de filas, considerapolars.read_databaseoconnectorx.
Enlaces
Relacionados en esta página
SQLAlchemy, la pieza sobre la queread_sqlse apoya.polarsyduckdb, alternativas modernas más rápidas para análisis.
DuckDB
DuckDB es una base de datos analítica embebida (in-process), columnar y vectorizada. La analogía habitual es “SQLite para análisis”: no hay servidor, no hay configuración, el motor vive dentro del proceso Python. La diferencia clave con SQLite es la arquitectura columnar y el ejecutor vectorizado, que la hacen órdenes de magnitud más rápida en queries analíticas sobre miles de millones de filas.
Particularmente eficaz consultando directamente ficheros Parquet, CSV o Arrow sin importarlos previamente. Lee y predica selectivamente sobre Parquet (pushdown de filtros), lo que permite trabajar con datasets más grandes que la memoria sin esfuerzo.
Cuándo usarlo
- Análisis interactivo sobre Parquet, CSV o Arrow locales (o en S3/GCS) sin montar infraestructura.
- ETL de tamaño medio (gigabytes a algunas decenas de TB con particionado) dentro de un único nodo.
- Sustituir SQLite en cualquier escenario donde el patrón de uso sea OLAP (agregaciones, joins amplios) y no OLTP.
- Como motor SQL detrás de una API analítica embebida en una aplicación Python.
Cuándo NO usarlo
- Cargas transaccionales concurrentes. DuckDB es de proceso único y de escritor único. Para OLTP usa PostgreSQL.
- Escalado horizontal. Es nodo único por diseño. Si necesitas distribuir, mira ClickHouse, BigQuery, Snowflake o Trino.
- Persistencia compartida entre procesos en escritura. Múltiples procesos escribiendo al mismo fichero
.duckdbno está soportado. Sí lo está la lectura concurrente.
Conceptos clave
- Modo en memoria vs fichero.
duckdb.connect()(sin path) es efímero.duckdb.connect("mi.duckdb")persiste. La sintaxis es idéntica. - Integración Arrow / pandas / polars zero-copy.
duckdb.sql("SELECT ... FROM my_df")consulta directamente unDataFramede pandas o polars que esté en el ámbito Python actual, sin copia ni registro explícito. - Lectura directa de ficheros.
SELECT * FROM 'data/*.parquet',read_csv_auto('data.csv'),read_json_auto(...). El optimizador empuja predicados y proyecciones al lector de Parquet. - Tipos. Soporte nativo para
DECIMAL,INTERVAL,STRUCT,LIST,MAP, tipos temporales con zona,UUID. Más rico que SQLite, más cercano a Postgres. - Httpfs.
INSTALL httpfs; LOAD httpfs;permite leer directamente de S3 / HTTPS.
Patrón mínimo
import duckdb
import polars as pl
# Consulta directa contra Parquet — sin importar
res = duckdb.sql("""
SELECT country, COUNT(*) AS n, AVG(revenue) AS avg_rev
FROM 'data/sales_*.parquet'
WHERE year = 2026
GROUP BY country
ORDER BY n DESC
""")
# Materializar a polars (zero-copy a Arrow, después a Polars)
df = res.pl()
# Consultar un DataFrame existente como si fuera tabla
users = pl.read_csv("users.csv")
duckdb.sql("SELECT * FROM users WHERE age > 30").show()
# Conexión persistente
con = duckdb.connect("warehouse.duckdb")
con.execute("CREATE TABLE IF NOT EXISTS events AS SELECT * FROM 'events.parquet'")
con.close()Trampas habituales
- Esperar semántica OLTP. No hay control de concurrencia multiproceso para escritura. Si dos procesos abren el mismo fichero
.duckdben modo lectura/escritura, fallan o corrompen. SELECT * FROM dfcondfambiguo. El nombre Python tiene que estar en el ámbito visible para DuckDB. Si lo creas dentro de una función y consultas fuera, no lo encuentra.- Memoria con joins amplios. Aunque es muy eficiente, joins entre tablas grandes sin predicado pueden hinchar memoria. Configura
SET memory_limit = '8GB'ySET temp_directory = '...'para que use disco si hace falta. - Versiones del fichero. El formato
.duckdbevoluciona y entre minor versions a veces requiereEXPORT DATABASEyIMPORT DATABASE. No confíes en compatibilidad eterna del binario sin probarlo. - Tipos
DECIMALconfrom_pandas. Pandas no tieneDECIMALnativo y convierte afloat64. Si necesitas precisión exacta (finanzas), pasa por Arrow o construye desde Polars conpl.Decimal.
Enlaces
Relacionados en esta página
polars, comparte filosofía vectorizada. Integración bidireccional zero-copy.ibis, DuckDB es uno de los backends mejor soportados de Ibis.
Polars (SQL)
polars es un DataFrame vectorizado en Rust con API Python idiomática. Su pieza relevante para esta página es pl.SQLContext: un motor SQL ligero que permite consultar LazyFrames y DataFrames con SQL estándar, además de pl.read_database para extraer datos directamente de una base relacional a un DataFrame Polars.
No pretende competir con DuckDB en alcance del dialecto, pero cierra muy bien el ciclo cuando ya estás trabajando en Polars y prefieres expresar una consulta en SQL en vez de en la API de expresiones.
Cuándo usarlo
- Pipelines que ya viven en Polars y donde puntualmente conviene SQL (joins complejos, lectores SQL existentes).
- Extracción rápida de una base a
DataFrameconpl.read_database, más rápido quepandas.read_sqlpara volúmenes medios y altos, sobre todo conconnectorxcomo backend. - Análisis en streaming sobre ficheros grandes:
scan_parquet+SQLContextmantiene el modo lazy y solo materializa al final.
Cuándo NO usarlo
- Como motor de consulta primario sobre Parquet de gran escala. DuckDB ofrece más SQL, mejor pushdown y mejor planificador analítico.
- Cuando necesitas todas las funciones de SQL. El dialecto de
SQLContextes menos completo que el de DuckDB. Ciertas funciones de ventana o agregaciones avanzadas todavía no están. - Para escribir en bases relacionales.
polarstienewrite_database, pero el caso de uso natural es lectura + transformación, no escritura transaccional.
Conceptos clave
pl.SQLContextregistraDataFrames yLazyFrames como tablas y permite consultarlos con SQL. Mantiene la pereza: el plan se compila al ejecutor de Polars.pl.read_database(query, connection)carga el resultado a unDataFrame. Acepta una URL ADBC, una conexión SQLAlchemy o, idealmente, una deconnectorx(mucho más rápida en lecturas masivas).- Modo lazy. Con
scan_*ySQLContext(eager=False), la consulta SQL se traduce al plan lazy de Polars y se optimiza junto con el resto del pipeline (pushdown de proyección y predicado). - Tipos. Polars usa Arrow internamente, así que el tipado es estricto. Las fechas con zona se preservan. Los
Decimalrequieren tipo explícito.
Patrón mínimo
import polars as pl
# Consultar un DataFrame existente con SQL
df = pl.DataFrame({
"user_id": [1, 2, 3, 4],
"country": ["ES", "FR", "ES", "DE"],
"revenue": [100, 200, 150, 300],
})
ctx = pl.SQLContext(users=df)
res = ctx.execute("""
SELECT country, SUM(revenue) AS total
FROM users
GROUP BY country
ORDER BY total DESC
""", eager=True)
# Lectura directa desde una base relacional (vía connectorx)
df_db = pl.read_database_uri(
query="SELECT id, email FROM users WHERE country = 'ES'",
uri="postgresql://user:pass@host/db",
engine="connectorx",
)
# Modo lazy sobre Parquet grande
lf = pl.scan_parquet("events/*.parquet")
ctx = pl.SQLContext(events=lf)
plan = ctx.execute("SELECT user_id, COUNT(*) FROM events GROUP BY user_id")
out = plan.collect() # materializa solo aquíTrampas habituales
pl.read_databasecon SQLAlchemy es lento. El backend por defecto pasa por filas DB-API. Para volumen, usapl.read_database_uri(..., engine="connectorx")o “adbc”, extracción zero-copy a Arrow.- Dialecto incompleto. Funciones específicas (algunas de ventana, ciertos casts) pueden no estar. El mensaje de error a veces es críptico. Verifica contra la matriz de soporte en la documentación.
- Confundir
read_databaseconread_database_uri. El primero acepta una conexión. El segundo, una URI. Los rendimientos no son equivalentes según el backend. - Conversión
to_pandas()con tipos extendidos. Si tuDataFrametienepl.Decimalopl.Datetimecon zona,to_pandas()puede degradar tipos. Especificause_pyarrow_extension_array=True.
Enlaces
Relacionados en esta página
duckdb, alternativa con SQL más completo. Integración bidireccional zero-copy.pandas.read_sql, equivalente más lento para extracción aDataFrame.
Ibis
Ibis es una DSL Python para construir expresiones de tabla que se compilan a SQL del backend que elijas (DuckDB, BigQuery, Snowflake, Postgres, ClickHouse, Trino, Spark, Polars…). La idea central: escribes una vez en Python con una API tipo dataframe-on-tables, y el mismo código se ejecuta contra cualquier motor compatible. Es la respuesta moderna al problema de portabilidad multi-motor, el espacio que durante años ocupó SQLAlchemy Core con menos azúcar.
Mantenido por Voltron Data, los mismos detrás de Arrow y muchos del equipo original de Ibis en Cloudera. Especialmente útil cuando tus datos viven en varios sitios (lago Parquet + warehouse + Postgres operacional) y quieres una capa común.
Cuándo usarlo
- Mismo análisis contra múltiples backends. Notebook local en DuckDB, producción en BigQuery o Snowflake.
- Pipelines complejos con joins y agregaciones donde escribir SQL plano se vuelve frágil. Las expresiones Ibis son componibles y refactorizables.
- Cuando quieres tipado y autocompletado sobre columnas y resultados, en vez de cadenas SQL.
Cuándo NO usarlo
- Una sola base y SQL ya bien dominado. La capa de abstracción cuesta. Si solo trabajas contra Postgres y conoces el dialecto, escribir SQL directo es más rápido y más legible para el equipo.
- Necesitas usar funciones muy específicas del backend sin equivalente en Ibis. Aunque hay escape hatches (
@ibis.udf, raw SQL), pelearse contra la abstracción anula su ventaja. - DDL pesado. Ibis es fuerte en lectura y consulta. Para migraciones y DDL transaccional, usa Alembic + SQLAlchemy o herramientas dedicadas (Liquibase, dbt si encaja en el flujo).
Conceptos clave
- Backend explícito.
ibis.connect("duckdb://..."),ibis.duckdb.connect(...),ibis.bigquery.connect(...). La misma expresión se evalúa distinto según el backend. - Expresiones perezosas.
t.filter(...).group_by(...).aggregate(...)no ejecuta nada hasta.execute()o.to_pandas() / .to_polars(). Permite componer y reusar fragmentos. ibis.show_sql(expr)imprime el SQL generado. Imprescindible en debugging, y útil como aprendizaje del dialecto.- Tipos. Sistema de tipos propio (
int64,string,timestamp("UTC"),array,struct) que se mapea al del backend. Errores de tipo se detectan en construcción, no en ejecución. ibis-frameworkvsibis-*backends. Cada backend es una dependencia opcional (pip install 'ibis-framework[duckdb,postgres]').
Patrón mínimo
import ibis
ibis.options.interactive = True # ejecución eager para exploración
con = ibis.duckdb.connect() # o ibis.connect("postgres://...")
t = con.read_parquet("sales_*.parquet", "sales")
expr = (
t.filter(t.year == 2026)
.group_by("country")
.aggregate(
n=t.count(),
avg_rev=t.revenue.mean(),
)
.order_by(ibis.desc("n"))
)
# Inspecciona el SQL generado
print(ibis.to_sql(expr))
# Ejecuta y materializa
df = expr.to_pandas() # o .to_polars()Trampas habituales
- Asumir que todo el dialecto está soportado. Algunas funciones específicas (ciertas funciones de ventana avanzadas, GIS, JSON nested) varían por backend. Verifica con
ibis.show_sqlantes de confiar. - Mezclar UDFs y portabilidad. Una UDF Python registrada en DuckDB no funcionará en BigQuery. Si la portabilidad es objetivo, mantén las UDFs en una capa configurable.
- Conexiones por defecto efímeras.
ibis.duckdb.connect()sin path crea una BD en memoria, distinta cada vez. Para persistir, pasa un path. interactive=Trueen producción. Está pensado para notebooks. En código de aplicación produce ejecuciones implícitas inesperadas. Déjalo solo en exploración.
Enlaces
Relacionados en esta página
SQLAlchemy, alternativa más madura cuando el backend es una BD relacional única.duckdb, uno de los backends preferidos de Ibis. Los dos se usan juntos con naturalidad.
psycopg
psycopg (versión 3, sucesora de psycopg2) es el driver canónico de PostgreSQL para Python. Implementa la DB-API 2.0, soporta el protocolo binario nativo de Postgres y cubre las funcionalidades avanzadas del motor: COPY, LISTEN/NOTIFY, prepared statements, tipos compuestos, arrays, JSONB, RETURNING, server-side cursors para resultados grandes.
psycopg2 sigue funcionando pero está en mantenimiento. Los proyectos nuevos deberían empezar en psycopg (3.x). API muy similar, mejoras importantes en async, soporte de tipos y rendimiento.
Cuándo usarlo
- Acceso directo a Postgres cuando SQLAlchemy es excesivo (scripts cortos, herramientas administrativas).
- Operaciones nativas de Postgres sin equivalente en abstracciones más altas:
COPYpara cargas masivas,LISTEN/NOTIFYpara pub/sub ligero, server-side cursors para streaming. - Como driver por debajo de SQLAlchemy (
postgresql+psycopg://...).
Cuándo NO usarlo
- Portabilidad multi-motor. Si tu código debe ejecutarse contra MySQL o SQL Server además de Postgres, usa SQLAlchemy.
- Servicios asíncronos de muy alto throughput.
asyncpges notablemente más rápido en su nicho (Postgres + asyncio).psycopgasync funciona, peroasyncpgestá optimizado para ese caso concreto.
Conceptos clave
- DB-API 2.0.
connect(),cursor(),execute(sql, params),fetchone() / fetchall() / fetchmany(). Parametrización con%so%(name)s, nunca con f-strings. - Autocommit. Por defecto,
psycopgabre una transacción implícita en el primer comando.conn.autocommit = Truecambia a modo autocommit (cada sentencia compromete sola). Imprescindible paraVACUUM,CREATE DATABASEy otras sentencias que no pueden ir en transacción. COPY. Para cargas masivas,cursor.copy("COPY t FROM STDIN")es órdenes de magnitud más rápido queINSERTpor fila.- Server-side cursors.
conn.cursor(name="cur")crea un cursor named que mantiene el resultado en el servidor, esencial para iterar sobre resultados de millones de filas sin agotar memoria del cliente. - Adaptadores de tipos. Mapeo automático entre tipos Python y tipos Postgres (
uuid,datetime,Decimal,dict→JSONB, listas →array). Registrables y extensibles.
Patrón mínimo
import psycopg
# Context manager: connect / cursor / commit / close manejados
with psycopg.connect(
"postgresql://user:pass@localhost:5432/mydb",
autocommit=False,
) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, email FROM users WHERE country = %s",
("ES",),
)
for row in cur:
print(row)
# Carga masiva con COPY
with psycopg.connect("...") as conn, conn.cursor() as cur:
with cur.copy("COPY events (user_id, ts, payload) FROM STDIN") as cp:
for record in records:
cp.write_row(record)
# Server-side cursor para streaming
with psycopg.connect("...") as conn:
with conn.cursor(name="big_query") as cur:
cur.itersize = 10_000
cur.execute("SELECT * FROM huge_table")
for row in cur:
process(row)Trampas habituales
- Concatenar parámetros en la consulta. Antipatrón clásico que abre inyección SQL. Usa siempre
%so%(name)sy pasa los valores como tupla/dict. Para nombres de tabla o columna dinámicos, usapsycopg.sql.Identifier/SQL. - No cerrar transacciones. Si abres conexión sin
withy olvidascommit()orollback(), los cambios se pierden y mantienes locks en el servidor. Elwith conn:(context manager de conexión) confirma al salir si no hubo excepción. autocommit=Truepor defecto en código de dominio. Pierdes atomicidad en operaciones que deben ir juntas. Úsalo deliberadamente en sentencias que no admiten transacción, no como configuración global.- Mezclar zonas horarias. Postgres distingue
TIMESTAMP(sin zona) deTIMESTAMPTZ(con zona, almacenado en UTC).psycopgconvierteTIMESTAMPTZadatetimecontzinfo.TIMESTAMPadatetimenaive. Pasar undatetimenaive a una columnaTIMESTAMPTZproduce resultados según elTIMEZONEde la sesión, y rara vez el que esperas. fetchall()sobre resultados gigantes. Trae todo a memoria del cliente. Para tablas grandes, usa cursores named (server-side) ofetchmany(N)por lotes.
Enlaces
Relacionados en esta página
SQLAlchemy, lo usa como driver por debajo.asyncpg, alternativa async-first para Postgres.
asyncpg
asyncpg es un driver Postgres puro nativo y asincrónico mantenido por MagicStack. A diferencia de psycopg async, no es una adaptación: está construido sobre asyncio desde cero y habla directamente el protocolo binario de Postgres. En benchmarks es típicamente de 2 a 5 veces más rápido que las alternativas en cargas async puras.
No implementa DB-API 2.0, su API es propia y asíncrona. Eso lo hace incompatible con SQLAlchemy Core sin un adaptador (existe: sqlalchemy-asyncpg).
Cuándo usarlo
- Servicios
asynciocon alto throughput de queries contra Postgres (APIs FastAPI/Starlette, workers async, procesadores de stream). - Cuando el cuello de botella medido es el driver y necesitas exprimir microsegundos.
- Como driver detrás de
SQLAlchemy AsyncEngine(postgresql+asyncpg://...).
Cuándo NO usarlo
- Código síncrono. No hay manera de usarlo bloqueante. Para sync,
psycopg. - Si no necesitas el rendimiento. El ecosistema sync (
psycopg+ SQLAlchemy) es más maduro, con más herramientas (migrations, debuggers, profiling). El async paga complejidad en debugging y manejo de errores. - Portabilidad. Solo Postgres. No hay equivalente directo para MySQL u otros motores.
Conceptos clave
- Pool.
asyncpg.create_pool(dsn, min_size=..., max_size=...)crea el pool a nivel de aplicación.async with pool.acquire() as conn:saca una conexión. - Parametrización con
$1, $2, ...(sintaxis nativa de Postgres, no DB-API).conn.fetch("SELECT ... WHERE id = $1", user_id). - API tipada.
fetch()devuelvelist[Record],fetchrow()unRecordoNone,fetchval()un valor escalar,execute()para sentencias sin resultados. - Prepared statements.
await conn.prepare(sql)devuelve un objeto reutilizable. El pool los cachea por conexión automáticamente cuando llamasfetchvarias veces con la misma SQL. - Tipos. Mapeo nativo a Python para casi todos los tipos Postgres, incluyendo zona horaria en
TIMESTAMPTZ. Adaptadores personalizados conconn.set_type_codec.
Patrón mínimo
import asyncio
import asyncpg
async def main():
# Pool a nivel de aplicación
pool = await asyncpg.create_pool(
dsn="postgresql://user:pass@localhost/db",
min_size=2,
max_size=10,
)
async with pool.acquire() as conn:
# Lectura parametrizada
rows = await conn.fetch(
"SELECT id, email FROM users WHERE country = $1",
"ES",
)
# Transacción explícita
async with conn.transaction():
await conn.execute(
"UPDATE users SET active = $1 WHERE id = $2",
False, 1,
)
await conn.execute(
"INSERT INTO audit (user_id, action) VALUES ($1, $2)",
1, "deactivate",
)
await pool.close()
asyncio.run(main())Trampas habituales
- Mezclar
%scon$1.asyncpgusa solo la sintaxis nativa de Postgres ($1, $2, ...).%s(DB-API) no funciona y produce errores confusos. - Compartir conexiones entre tareas. Una conexión
asyncpgno es thread-safe ni task-safe para uso concurrente. Saca una conexión del pool por tarea (async with pool.acquire()). - Olvidar
pool.close(). Cerrar el pool drena conexiones limpiamente. En servicios FastAPI/Starlette, gestiónalo en lifespan. - Tipos numéricos vs
Decimal. Por defecto,NUMERICse convierte adecimal.Decimal. Para cast afloat, registra un codec. no lo hagas a menos que el dominio lo permita (finanzas suele exigir precisión exacta). - Errores envueltos en otra excepción. En código async con muchos
await, los stack traces son menos directos. Configuraasyncpg.exceptionsexplícitamente en handlers en vez de tragar excepciones genéricas.
Enlaces
Relacionados en esta página
psycopg, equivalente síncrono y opción async alternativa.SQLAlchemy, puede usarasyncpgcomo driver conAsyncEngine.
pyodbc
pyodbc es el driver ODBC para Python. Más que un driver, es un puente que delega en los drivers ODBC del sistema operativo (Microsoft ODBC Driver for SQL Server, FreeTDS, drivers de Oracle, IBM Db2, Snowflake…). Implementa DB-API 2.0.
En la práctica, lo encuentras casi siempre en escenarios SQL Server (Windows / Azure SQL / on-prem) y en migraciones desde sistemas legacy que solo exponen ODBC.
Cuándo usarlo
- SQL Server / Azure SQL Database: la combinación más habitual.
pyodbc+ ODBC Driver 18 for SQL Server es la ruta canónica. - Sistemas legacy donde el único driver disponible es ODBC.
- Cuando tu organización ya tiene infraestructura ODBC homogénea (DSNs configurados, drivers instalados centralmente).
Cuándo NO usarlo
- Postgres o MySQL. Hay drivers nativos mejores (
psycopg,mysqlclient/PyMySQL). - Contenedores ligeros. ODBC requiere instalar el driver del SO (
msodbcsql18en Debian, equivalentes en Alpine). Añade superficie de imagen y dependencias. - Async. No hay soporte async razonable. Para SQL Server async, mira
aioodbc(wrapper sobrepyodbcen hilos) opymssqlasync. Ambos son menos maduros que el ecosistema síncrono.
Conceptos clave
- Cadena de conexión. Formato propio de ODBC:
DRIVER={ODBC Driver 18 for SQL Server};SERVER=...;DATABASE=...;UID=...;PWD=...;TrustServerCertificate=yes. Larga, propensa a errores. Centralízala en configuración. - Driver del SO.
pyodbcno incluye los drivers. En Linux:apt install msodbcsql18 unixodbc-dev. En Windows: instalador de Microsoft. En CI/Docker, instalarlo es paso obligatorio. - Parametrización con
?. No%s, no:name.cur.execute("SELECT ... WHERE id = ?", user_id). fast_executemany=Trueacelera enormementeexecutemany()en cargas masivas a SQL Server. Por defecto está desactivado por razones históricas.- Encoding. Históricamente fuente de dolor con caracteres no ASCII. ODBC Driver 17+ y
pyodbcrecientes lo gestionan bien con UTF-8. En drivers antiguos, configuracnxn.setdecoding(pyodbc.SQL_CHAR, encoding="utf-8").
Patrón mínimo
import pyodbc
conn_str = (
"DRIVER={ODBC Driver 18 for SQL Server};"
"SERVER=myserver.database.windows.net;"
"DATABASE=mydb;"
"UID=myuser;"
"PWD=mypassword;"
"Encrypt=yes;"
"TrustServerCertificate=no;"
)
with pyodbc.connect(conn_str) as conn:
with conn.cursor() as cur:
# Lectura parametrizada
cur.execute(
"SELECT id, email FROM users WHERE country = ?",
"ES",
)
for row in cur.fetchall():
print(row.id, row.email)
# Carga masiva
with pyodbc.connect(conn_str) as conn:
cur = conn.cursor()
cur.fast_executemany = True
cur.executemany(
"INSERT INTO events (user_id, ts) VALUES (?, ?)",
records,
)
conn.commit()Trampas habituales
- Driver no instalado.
pyodbc.InterfaceError: ('IM002', '... Data source name not found')casi siempre significa que el driver ODBC del SO falta. Verifica conodbcinst -q -d(Linux) o el administrador de orígenes de datos ODBC (Windows). - TLS. Por defecto el driver moderno exige cifrado.
Encrypt=yes;TrustServerCertificate=yessalta validación. Úsalo solo en entornos de desarrollo. En producción, instala el certificado correcto. fast_executemanyy tipos. Acelera muchoexecutemanypero es estricto con tipos: pasarNonemezclado conDecimalen la misma columna puede fallar. Asegura tipado homogéneo del batch.- Encoding silente. En sistemas con ODBC Driver 13 o anteriores, columnas
NVARCHARcon caracteres acentuados aparecen corruptas. Solución: actualizar el driver del SO, no la librería Python. - Cadenas de conexión en código. Acaban en commits. Usa variables de entorno o un gestor de secretos. Nunca hardcodees
PWD=.
Enlaces
Relacionados en esta página
SQLAlchemy, puede usarpyodbccomo driver conmssql+pyodbc://.psycopgyasyncpg, equivalentes nativos para Postgres.