Indice / Entrada y salida / 7.5
El puente con SQL
Esta lección no enseña SQL. Enseña que una base de datos es otra fuente de datos más, y que las dos funciones de pandas que la conectan son todo lo que hace falta para entrar y salir.
Para qué sirve
Tarde o temprano los datos no están en un archivo: están en una base. Y ahí aparece la pregunta de si hay que aprender SQL antes de poder trabajar.
La respuesta corta es no. Con read_sql_query traés una tabla y del
otro lado tenés un DataFrame como cualquier otro; con to_sql escribís uno.
Lo que sí conviene es reconocer un SELECT, porque vas a
encontrarlos escritos por otra gente - y darte cuenta de que ya conocés lo que
hacen.
ELI5Explicado facil
Si sabés pedir un café en español y te mudás a un país donde se pide en otro idioma, no tenés que aprender el idioma entero: alcanza con reconocer las cuatro palabras del pedido.
SQL tiene cuatro palabras que ya sabés usar con otro nombre: elegir columnas, filtrar filas, ordenar y agrupar. Es el mismo vocabulario con otra sintaxis.
Las dos funciones
import sqlite3
import pandas as pd
# Una base en memoria: no deja ningun archivo y sirve para practicar.
conexion = sqlite3.connect(":memory:")
pedidos = pd.read_csv("tienda_pedidos.csv")
clientes = pd.read_csv("tienda_clientes.csv")
# to_sql escribe un DataFrame como una tabla.
pedidos.to_sql("pedidos", conexion, index=False, if_exists="replace")
clientes.to_sql("clientes", conexion, index=False, if_exists="replace")
tablas = pd.read_sql_query(
"SELECT name FROM sqlite_master WHERE type = 'table'", conexion)
print("tablas creadas:", list(tablas["name"]))
# read_sql_query devuelve un DataFrame, igual que read_csv.
primeros = pd.read_sql_query("SELECT * FROM pedidos LIMIT 3", conexion)
print()
print(primeros.to_string(index=False))
print()
print("tipo de lo que devuelve:", type(primeros).__name__)
print("dtypes:")
print(primeros.dtypes.to_string())
conexion.close()
print()
print("Eso es todo el puente: to_sql para escribir, read_sql_query para")
print("leer. Del otro lado hay un DataFrame como cualquier otro.")
tablas creadas: ['pedidos', 'clientes']
id cliente_id total creado estado
101 32 374.94 2026-07-08 22:39:00 preparando
102 37 223.65 2026-04-18 02:08:00 entregado
103 26 605.95 2026-02-22 13:19:00 nuevo
tipo de lo que devuelve: DataFrame
dtypes:
id int64
cliente_id int64
total float64
creado str
estado str
Eso es todo el puente: to_sql para escribir, read_sql_query para
leer. Del otro lado hay un DataFrame como cualquier otro.
sqlite3 viene con Python
No hay que instalar nada. sqlite3.connect(":memory:") crea una base que
vive en memoria y desaparece al cerrar: perfecta para practicar. Con una ruta de
archivo, la base queda en el disco.
Para PostgreSQL o MySQL cambia solo la forma de conectarse (con
SQLAlchemy); read_sql_query y to_sql se usan
igual.
El mismo vocabulario
import sqlite3
import pandas as pd
conexion = sqlite3.connect(":memory:")
pedidos = pd.read_csv("tienda_pedidos.csv")
pedidos.to_sql("pedidos", conexion, index=False, if_exists="replace")
consulta = """
SELECT id, cliente_id, total
FROM pedidos
WHERE total > 400 AND estado = 'entregado'
ORDER BY total DESC, id ASC
LIMIT 5
"""
en_sql = pd.read_sql_query(consulta, conexion)
print("SQL:")
print(en_sql.to_string(index=False))
en_pandas = (pedidos
.loc[(pedidos["total"] > 400) & (pedidos["estado"] == "entregado"),
["id", "cliente_id", "total"]]
.sort_values(["total", "id"], ascending=[False, True],
kind="stable")
.head(5)
.reset_index(drop=True))
print()
print("pandas:")
print(en_pandas.to_string(index=False))
print()
print("iguales:", bool(en_sql.equals(en_pandas)))
conexion.close()
print()
print("SELECT es df[[...]], WHERE es la mascara, ORDER BY es sort_values")
print("y LIMIT es head. Es el mismo vocabulario con otra sintaxis.")
SQL:
id cliente_id total
126 30 1799.85
116 7 1786.41
167 28 1563.80
206 3 1443.31
200 2 1438.08
pandas:
id cliente_id total
126 30 1799.85
116 7 1786.41
167 28 1563.80
206 3 1443.31
200 2 1438.08
iguales: True
SELECT es df[[...]], WHERE es la mascara, ORDER BY es sort_values
y LIMIT es head. Es el mismo vocabulario con otra sintaxis.
| SQL | pandas |
|---|---|
SELECT a, b | df[["a", "b"]] |
WHERE total > 400 | df[df["total"] > 400] |
AND / OR / NOT | & / | / ~, con paréntesis |
IN (...) | .isin([...]) |
BETWEEN a AND b | .between(a, b) |
IS NULL | .isna() |
ORDER BY x DESC | sort_values("x", ascending=False) |
LIMIT 5 | .head(5) |
GROUP BY c | .groupby("c") |
COUNT(*) / SUM(x) | .size() / ["x"].sum() |
INNER JOIN ... ON | merge(..., how="inner", on=) |
LEFT JOIN | how="left" |
SELECT DISTINCT | .drop_duplicates() |
JOIN es merge
import sqlite3
import pandas as pd
conexion = sqlite3.connect(":memory:")
pedidos = pd.read_csv("tienda_pedidos.csv")
clientes = pd.read_csv("tienda_clientes.csv")
pedidos.to_sql("pedidos", conexion, index=False, if_exists="replace")
clientes.to_sql("clientes", conexion, index=False, if_exists="replace")
consulta = """
SELECT p.id, p.total, c.nombre
FROM pedidos AS p
INNER JOIN clientes AS c ON p.cliente_id = c.id
ORDER BY p.id
"""
en_sql = pd.read_sql_query(consulta, conexion)
en_pandas = (pedidos.merge(clientes, left_on="cliente_id", right_on="id",
how="inner", suffixes=("", "_cliente"))
.rename(columns={"id": "id_pedido"})
[["id_pedido", "total", "nombre"]]
.rename(columns={"id_pedido": "id"})
.sort_values("id", kind="stable")
.reset_index(drop=True))
print("SQL :", en_sql.shape)
print("pandas:", en_pandas.shape)
print()
print(en_sql.head(3).to_string(index=False))
try:
pd.testing.assert_frame_equal(en_sql, en_pandas)
print()
print("assert_frame_equal: los dos caminos dan EXACTAMENTE lo mismo.")
except AssertionError as error:
print("difieren:", str(error).splitlines()[0])
print()
perdidos = len(pedidos) - len(en_sql)
print("pedidos:", len(pedidos), "| resultado:", len(en_sql),
"| se perdieron:", perdidos)
print("Son pedidos de un cliente_id que no esta en la tabla de clientes.")
print("INNER JOIN los descarta, igual que how='inner' y con el mismo")
print("silencio: la unica senal es que el resultado tiene menos filas.")
conexion.close()
SQL : (119, 3)
pandas: (119, 3)
id total nombre
101 374.94 Lautaro Martínez
102 223.65 Marta Bravo
103 605.95 Delfina Ángel
assert_frame_equal: los dos caminos dan EXACTAMENTE lo mismo.
pedidos: 120 | resultado: 119 | se perdieron: 1
Son pedidos de un cliente_id que no esta en la tabla de clientes.
INNER JOIN los descarta, igual que how='inner' y con el mismo
silencio: la unica senal es que el resultado tiene menos filas.
Los dos caminos dan exactamente el mismo DataFrame, comprobado con
assert_frame_equal. Y los dos pierden el mismo pedido, con el mismo
silencio: hay un cliente_id que no está en la tabla de clientes, y el
INNER JOIN lo descarta igual que how="inner".
Un JOIN pierde filas sin avisar, igual que un merge. Un
GROUP BY no crea los períodos vacíos, igual que un groupby.
Y una clave repetida multiplica filas de los dos lados.
Cambiar de herramienta no cambia el problema. Lo que aprendiste en la unidad 6 vale igual del otro lado del puente.
Parámetros
import sqlite3
import pandas as pd
conexion = sqlite3.connect(":memory:")
pd.read_csv("tienda_pedidos.csv").to_sql("pedidos", conexion, index=False,
if_exists="replace")
# La forma correcta: la consulta lleva ? y los valores van aparte.
estado = "entregado"
minimo = 400
seguro = pd.read_sql_query(
"SELECT COUNT(*) AS n FROM pedidos WHERE estado = ? AND total > ?",
conexion, params=(estado, minimo))
print("con params:", int(seguro["n"].item()), "pedidos")
# La forma peligrosa: pegar el valor adentro del texto de la consulta.
inseguro = pd.read_sql_query(
f"SELECT COUNT(*) AS n FROM pedidos WHERE estado = '{estado}'", conexion)
print("con f-string:", int(inseguro["n"].item()), "pedidos")
print("Los dos funcionan. El problema aparece con OTRO valor.")
print()
# Un valor con una comilla adentro rompe la consulta armada con f-string.
raro = "O'Brien"
try:
pd.read_sql_query(
f"SELECT COUNT(*) AS n FROM pedidos WHERE estado = '{raro}'", conexion)
except Exception as error:
print("con f-string y un apostrofe ->", type(error).__name__)
con_params = pd.read_sql_query(
"SELECT COUNT(*) AS n FROM pedidos WHERE estado = ?", conexion,
params=(raro,))
print("con params y el mismo valor:", int(con_params["n"].item()),
"-> sin error")
print()
print("Ese apostrofe es la version inocente. La version maliciosa es un")
print("valor que cierra la comilla y agrega otra instruccion. Con params,")
print("el valor NUNCA se interpreta como codigo: es un dato y nada mas.")
conexion.close()
con params: 14 pedidos
con f-string: 19 pedidos
Los dos funcionan. El problema aparece con OTRO valor.
con f-string y un apostrofe -> DatabaseError
con params y el mismo valor: 0 -> sin error
Ese apostrofe es la version inocente. La version maliciosa es un
valor que cierra la comilla y agrega otra instruccion. Con params,
el valor NUNCA se interpreta como codigo: es un dato y nada mas.
Los dos ejemplos funcionan con un valor normal. Con un valor que tiene un apóstrofe
- O'Brien - la versión con f-string falla,
porque la comilla cierra la cadena de SQL y lo que sigue se interpreta como código.
Ese apóstrofe es la versión inocente del problema. La versión maliciosa es un valor
elegido para cerrar la comilla y agregar otra instrucción, que es lo que se llama
inyección SQL. Con params=, el valor nunca se
interpreta como código: viaja aparte de la consulta y es un dato y nada más.
No es una recomendación de estilo: es la única forma correcta de pasar un valor a una consulta, siempre, incluso cuando el valor lo escribiste vos.
Dónde conviene hacer cada cosa
import sqlite3
import pandas as pd
conexion = sqlite3.connect(":memory:")
pd.read_csv("tienda_pedidos.csv").to_sql("pedidos", conexion, index=False,
if_exists="replace")
# Opcion A: que la base agregue y devuelva 39 filas.
en_sql = pd.read_sql_query("""
SELECT cliente_id, COUNT(*) AS pedidos, ROUND(SUM(total), 2) AS total
FROM pedidos
GROUP BY cliente_id
ORDER BY cliente_id
""", conexion)
# Opcion B: traer las 120 filas y agregar en pandas.
todo = pd.read_sql_query("SELECT * FROM pedidos", conexion)
en_pandas = (todo.groupby("cliente_id", as_index=False)
.agg(pedidos=("id", "size"), total=("total", "sum"))
.assign(total=lambda d: d["total"].round(2))
.sort_values("cliente_id", kind="stable")
.reset_index(drop=True))
print("SQL :", en_sql.shape, "filas que viajaron:", len(en_sql))
print("pandas:", en_pandas.shape, "filas que viajaron:", len(todo))
print()
print(en_sql.head(4).to_string(index=False))
print()
iguales = en_sql.equals(en_pandas)
print("dan lo mismo:", bool(iguales))
if not iguales:
dif = en_sql.compare(en_pandas)
print("primeras diferencias:")
print(dif.head(3).to_string())
conexion.close()
print()
print("Con 120 filas da igual. Con 50 millones no: agregar en la base hace")
print("que viajen 39 filas en vez de 50 millones. La regla practica es")
print("filtrar y agregar del lado de la base, y hacer en pandas lo que la")
print("base no sabe hacer.")
SQL : (39, 3) filas que viajaron: 39
pandas: (39, 3) filas que viajaron: 120
cliente_id pedidos total
1 2 2806.17
2 5 3193.50
3 3 4190.86
5 2 2304.34
dan lo mismo: True
Con 120 filas da igual. Con 50 millones no: agregar en la base hace
que viajen 39 filas en vez de 50 millones. La regla practica es
filtrar y agregar del lado de la base, y hacer en pandas lo que la
base no sabe hacer.
- En la base cuando el resultado es mucho más chico que la entrada: filtrar, agrupar y contar. En el ejemplo viajan 39 filas en vez de 120; con 50 millones de filas la diferencia es entre que ande y que no.
- En pandas cuando la operación es incómoda o imposible en SQL, o cuando ya trajiste los datos para otra cosa. Limpiar texto, imputar, sesionizar por huecos: todo eso se escribe mucho mejor de este lado.
La regla práctica: filtrar y agregar del lado de la base, y hacer en pandas
lo que la base no sabe hacer. Y no traer columnas que no vas a usar: un
SELECT * sobre una tabla ancha mueve datos por la red para nada.
Dónde se rompe
Causa: la consulta tiene un punto y coma en el medio, o un valor con comillas cerró la cadena y lo que sigue quedó como una instrucción aparte.
Arreglo: una sola sentencia por read_sql_query, y los
valores por params=.
Causa: INNER JOIN, que descarta lo que no encuentra pareja.
Es el mismo problema del how="inner".
Arreglo: LEFT JOIN si no querés perder nada, y contar la
diferencia igual.
Causa: SQLite no tiene tipo fecha: las guarda como texto. Otras bases sí,
y ahí vuelven como datetime.
Arreglo: parse_dates=["creado"] en
read_sql_query, o to_datetime después.
Causa: if_exists="replace", que elimina la tabla y
la vuelve a crear.
Arreglo: saber cuál de los tres querés: "fail" (el default,
falla si existe), "append" (agrega filas) o "replace"
(destruye y recrea). Sobre una base de producción, pensarlo dos veces.
Causa: SELECT * sobre una tabla grande, trayendo todo para
filtrar en pandas.
Arreglo: mover el WHERE y el GROUP BY a la
consulta. Lo que no viaja no cuesta nada.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
sqlite3.connect(":memory:") | Base en memoria | Para practicar |
sqlite3.connect("archivo.db") | Base en disco | Para que quede |
pd.read_sql_query(sql, con) | Consulta a DataFrame | Leer |
params=(a, b) | Valores aparte de la consulta | Siempre que haya un valor |
parse_dates=["f"] | Convierte al leer | SQLite guarda fechas como texto |
df.to_sql("t", con, index=False) | DataFrame a tabla | Escribir |
if_exists="append" | Agrega filas | Cargas incrementales |
if_exists="replace" | Destruye y recrea | Con cuidado |
chunksize=10000 | Escribe o lee de a pedazos | Tablas grandes |
SELECT name FROM sqlite_master | Qué tablas hay | Explorar una base desconocida |
con.close() | Cierra la conexión | Al terminar |