Saltar al contenido

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

EjemploUna base de datos es otra fuente de datos, con 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

EjemploLa misma pregunta, en SQL y en pandas
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
SQLpandas
SELECT a, bdf[["a", "b"]]
WHERE total > 400df[df["total"] > 400]
AND / OR / NOT& / | / ~, con paréntesis
IN (...).isin([...])
BETWEEN a AND b.between(a, b)
IS NULL.isna()
ORDER BY x DESCsort_values("x", ascending=False)
LIMIT 5.head(5)
GROUP BY c.groupby("c")
COUNT(*) / SUM(x).size() / ["x"].sum()
INNER JOIN ... ONmerge(..., how="inner", on=)
LEFT JOINhow="left"
SELECT DISTINCT.drop_duplicates()

JOIN es merge

EjemploJOIN y merge son la misma operacion
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".

Los problemas de la unidad 6 no desaparecen en SQL

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

EjemploParametros con ?, y por que nunca una f-string
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.
Nunca una f-string en una consulta

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

EjemploDonde conviene cada uno: agregar en SQL o traer y agregar
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.
¿Agregar en la base o traer y agregar en pandas?
  • 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

DatabaseError: You can only execute one statement at a time

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=.

(sin error) el resultado tiene menos filas de las esperadas

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.

(sin error) las fechas volvieron como texto

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.

(sin error) to_sql borró la tabla que ya existía

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.

(sin error) la consulta tarda muchísimo

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

El puente con SQL
ExpresiónQué haceCuándo
sqlite3.connect(":memory:")Base en memoriaPara practicar
sqlite3.connect("archivo.db")Base en discoPara que quede
pd.read_sql_query(sql, con)Consulta a DataFrameLeer
params=(a, b)Valores aparte de la consultaSiempre que haya un valor
parse_dates=["f"]Convierte al leerSQLite guarda fechas como texto
df.to_sql("t", con, index=False)DataFrame a tablaEscribir
if_exists="append"Agrega filasCargas incrementales
if_exists="replace"Destruye y recreaCon cuidado
chunksize=10000Escribe o lee de a pedazosTablas grandes
SELECT name FROM sqlite_masterQué tablas hayExplorar una base desconocida
con.close()Cierra la conexiónAl terminar

Ponete a prueba

1. ¿Que devuelve pd.read_sql_query(consulta, conexion)?Nivel 1 · Recordar
2. ¿Cual es el equivalente en pandas de WHERE total > 400 ORDER BY total DESC LIMIT 5?Nivel 1 · Recordar
3. Un INNER JOIN entre pedidos y clientes devuelve 119 filas de 120 pedidos. ¿Que paso?Nivel 2 · Predecir la salida
4. ¿Que hace to_sql(..., if_exists="replace") si la tabla ya existe?Nivel 2 · Predecir la salida
5. ¿Por que hay que pasar los valores con params= y nunca con una f-string?Nivel 3 · Elegir entre dos casi correctas
6. Una tabla tiene 50 millones de filas y necesitas el total por cliente (unos 40 valores). ¿Donde conviene agregar?Nivel 3 · Elegir entre dos casi correctas
7. Este codigo trae los pedidos de un estado y funciona bien durante meses. Un dia la consulta falla con un valor que vino de un archivo. ¿Por que?
estado = fila["estado"]
sql = f"SELECT * FROM pedidos WHERE estado = '{estado}'"
df = pd.read_sql_query(sql, con)
Nivel 4 · Encontrar el defecto

Prácticas de esta lección