
En resumen
- Antes de optimizar, mide: el cuello de botella casi nunca está donde crees.
- Para buscar pertenencia en 80,000 folios usa un set, no una lista.
- Procesa en flujo con generadores y lotes; no cargues la tabla completa.
- Un cron frecuente necesita candado y lotes idempotentes.
- El LLM es lo más lento y lo más caro: fíltralo con reglas deterministas antes de llamarlo.
La optimización de Python rara vez empieza por reescribir código. Empieza por saber dónde se va el tiempo. Lo aprendí con un sistema que gestionó un pool de 80,000 folios en producción, con verificación de comprobantes por OCR + LLM y sin revisión manual.
En este artículo reviso tres decisiones que definen el rendimiento de un script de procesamiento masivo: la estructura de datos, el manejo de memoria y la forma de repartir el trabajo. Cada una viene con código que puedes adaptar hoy.
Una aclaración antes de empezar: los ejemplos son ilustrativos. Muestran el patrón, no el código del cliente, y los nombres de tablas y formatos de folio son inventados para el ejemplo.
El caso: 80,000 folios, OCR y un LLM en producción
El sistema es el de Panini Fan Zone FWC 2026. Emitía y controlaba un pool fijo de 80,000 folios, y verificaba los comprobantes de compra que enviaban los participantes.
La verificación era un pipeline de dos pasos. Primero Tesseract extraía el texto de la imagen. Después un modelo LLaMA 3.3 70B, servido por Groq, validaba ese texto contra las reglas de la promoción.
Todo corría solo, con un cron cada 2 minutos, sobre un VPS con AlmaLinux. No había una persona revisando comprobante por comprobante.
Ese diseño impone tres restricciones que cualquier script de este tipo comparte:
- Cada corrida tiene una ventana corta. Si una ejecución tarda más de 2 minutos, se encima con la siguiente.
- La memoria es compartida. El script vive en el mismo servidor que el sitio; si se come la RAM, tira el sitio.
- El paso lento es externo. La llamada al LLM depende de la red y de los límites del proveedor, no de tu CPU.
Con eso en mente, veamos dónde sí vale la pena optimizar.
Optimizar scripts de Python con listas vs sets
El primer error es el más barato de corregir. Validar un folio es una pregunta de pertenencia: ¿este código existe en el pool?
Si el pool vive en una lista, Python compara elemento por elemento hasta encontrarlo. Con 80,000 folios, el peor caso son 80,000 comparaciones por cada consulta. Eso es O(n).
Un set guarda los elementos en una tabla hash. Calcula el hash del valor buscado y va directo a su posición. En promedio es O(1), sin importar si el pool tiene 800 o 80,000 elementos.
El cambio es una línea. La diferencia crece con el tamaño de los datos:
import timeit
folios = [f"FZ-{n:06d}" for n in range(80_000)]
folios_set = set(folios) # se construye una sola vez, al arrancar
buscado = "FZ-079999" # peor caso para la lista: está al final
# La lista recorre hasta encontrarlo: O(n) por cada consulta.
t_lista = timeit.timeit(lambda: buscado in folios, number=1_000)
# El set calcula un hash y va directo: O(1) en promedio.
t_set = timeit.timeit(lambda: buscado in folios_set, number=1_000)
# No confíes en cifras ajenas: mide en tu máquina, con tus datos.
print(f"lista: {t_lista:.4f} s set: {t_set:.4f} s")
Córrelo y compara. No pongo aquí un número porque depende de tu hardware y de tu versión de Python, y porque la lección no es la cifra: es que el costo de la lista crece con el pool y el del set no.
Tres detalles que evitan sorpresas:
- Construye el set una sola vez. Hacer
set(folios)dentro del ciclo cuesta O(n) en cada vuelta y borra toda la ganancia. - El set no conserva orden ni duplicados. Si los necesitas, mantén la lista y usa el set solo como índice de búsqueda.
- Para relacionar datos, usa un diccionario. Si además de saber que el folio existe necesitas su estado, un
dictde folio a registro te da la misma velocidad de acceso.
Y una regla de arquitectura: si el pool ya vive en MySQL con un índice único, la base de datos es tu set. Traer 80,000 filas a memoria para buscar una es el mismo error con otro disfraz.
Gestión de memoria en Python: procesa en flujo
Python libera la memoria por conteo de referencias. Cuando nadie apunta a un objeto, se destruye en ese momento. El recolector de basura solo interviene para los ciclos de referencias.
La consecuencia práctica es simple: la memoria no se libera mientras conserves una referencia. Y la forma más común de conservar referencias sin querer es acumular todo en una lista.
El patrón que lo provoca se ve inocente: un fetchall() que trae toda la tabla, una lista de resultados que crece durante la corrida, imágenes abiertas que nadie cierra.
La alternativa es procesar en flujo. Un generador entrega una fila a la vez y solo mantiene en memoria el lote actual:
def comprobantes_pendientes(conexion, lote=500, tope=5_000):
"""Entrega filas pendientes sin cargar la tabla completa."""
cursor = conexion.cursor(dictionary=True)
# El tope acota la corrida: lo que no alcance, lo toma el siguiente cron.
cursor.execute(
"SELECT id, ruta_imagen FROM comprobantes "
"WHERE estado = 'pendiente' ORDER BY id LIMIT %s",
(tope,),
)
while True:
filas = cursor.fetchmany(lote) # la memoria queda acotada al lote
if not filas:
break
yield from filas # el consumidor marca el ritmo
cursor.close()
El consumo de memoria deja de depender del tamaño de la tabla. Depende del tamaño del lote, y ese lo decides tú.
Con imágenes, la regla es la misma pero más urgente. Una foto de un comprobante ocupa mucho más en memoria, ya decodificada, que en disco. Ábrela con un bloque with, extrae el texto y suéltala antes de pasar a la siguiente.
Para diagnosticar, la biblioteca estándar ya trae la herramienta:
tracemalloctoma fotos de la memoria y te dice qué línea reservó cada bloque. Compara una foto al inicio contra otra después de varios lotes.cProfilete dice en qué funciones se va el tiempo. Úsalo antes de tocar una sola línea.__slots__reduce el tamaño de cada instancia cuando creas miles de objetos pequeños de una misma clase.
Llamar a gc.collect() a mano casi nunca es la solución. Si la memoria crece, alguien conserva una referencia. Encuéntrala.
Lotes idempotentes y concurrencia con límite
Un cron cada 2 minutos tiene un riesgo evidente: que una corrida lenta se encime con la siguiente y ambas procesen el mismo comprobante. Eso significa pagar dos veces el LLM y, peor, escribir dos resultados.
La defensa tiene dos capas. La primera es un candado de archivo: si la corrida anterior sigue viva, la nueva sale sin hacer nada. La segunda es que cada operación sea idempotente: procesar dos veces el mismo comprobante debe dejar el mismo resultado.
Sobre esa base se puede paralelizar. El OCR corre en un subproceso y el LLM es una llamada de red, así que el trabajo es de entrada y salida. Para eso los hilos sí sirven, aun con el GIL.
import fcntl
import sys
from concurrent.futures import ThreadPoolExecutor
# ⚠️ Producción: respalda la tabla antes de cambiar el flujo de estados.
candado = open("/var/lock/verificador.lock", "w")
try:
# Si la corrida anterior sigue viva, esta sale: nada se procesa doble.
fcntl.flock(candado, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit(0)
# Pocos workers a propósito: el límite real lo pone el proveedor del LLM.
with ThreadPoolExecutor(max_workers=4) as pool:
pendientes = comprobantes_pendientes(conexion)
for resultado in pool.map(verificar, pendientes):
guardar(resultado) # un solo hilo escribe: sin carreras en MySQL
Hay un detalle fino: pool.map consume el generador completo al encolar las tareas. Por eso la consulta lleva un tope. Sin él, el paralelismo te regresa al problema de memoria que acabas de resolver.
El número de workers no se elige por intuición. Se ajusta al límite de peticiones de tu proveedor y se sube solo si las mediciones lo justifican.
La misma disciplina aplica a las pruebas. En el módulo de registro de ese proyecto, la suite incluía una prueba de concurrencia con 50 workers simultáneos contra la misma ruta, para confirmar que solo un registro ganara cada cupo. La concurrencia se demuestra, no se supone.
Buenas prácticas para scripts de Python eficientes
Con la estructura de datos, la memoria y los lotes resueltos, lo que queda es orden. Estas son las prácticas que más rinden en un pipeline de OCR + LLM:
- Perfila antes de optimizar. Si el 90% del tiempo se va esperando al LLM, optimizar el ciclo de Python no cambia nada.
- Filtra barato antes de gastar caro. Si el OCR devolvió texto vacío o ilegible, rechaza ahí. No pagues una llamada al modelo para confirmar lo obvio.
- Valida la salida del LLM con reglas deterministas. Pide una respuesta estructurada y comprueba cada campo en código. El modelo propone; tus reglas deciden.
- Pon tiempo límite y reintentos con espera creciente. Una API remota va a fallar algún día. Que un fallo no detenga el lote completo.
- No repitas trabajo. Guarda el hash de cada imagen procesada. Un comprobante reenviado no debe costar otra vuelta de OCR y modelo.
- Registra por lote, no por línea. Cuántos entraron, cuántos pasaron, cuántos fallaron y cuánto tardó. Con eso detectas una degradación antes de que sea una caída.
Ninguna de estas prácticas es exótica. Lo difícil es aplicarlas todas a la vez, y hacerlo antes de que el volumen te obligue.
Lo que NO debes hacer
- Optimizar sin medir. Reescribir un ciclo con trucos ilegibles para ganar milisegundos, mientras el script pasa segundos esperando una API. Mide primero; optimiza solo lo que el perfil señale.
- Cargar todo en memoria «porque cabe». Cabe hoy, en tu máquina, con datos de prueba. En producción la tabla crece y el servidor se comparte con el sitio.
- Paralelizar sin límite. Lanzar cientos de hilos contra el LLM no acelera nada: dispara el límite de peticiones, llena el registro de errores y vuelve impredecible el costo.
- Confiar en el LLM como única validación. Un modelo de lenguaje puede equivocarse con total seguridad. Las reglas del negocio viven en código y en restricciones de la base de datos.
El siguiente paso
La optimización de Python que importa en producción no es un truco de sintaxis. Es elegir la estructura correcta, acotar la memoria, y repartir el trabajo sin que las corridas se pisen.
Si tu equipo enfrenta cuellos de botella en procesamiento masivo, pipelines de OCR o integraciones con LLM que no terminan de ser confiables, en Conqr Digital Agency diseñamos arquitecturas que escalan. Revisemos tu caso.
Si quieres ver el sistema completo detrás de este artículo, está documentado en el caso de Panini Fan Zone. Y si lo tuyo es automatizar procesos con IA, revisa el servicio de automatización con IA.
Preguntas frecuentes
- ¿Cuándo conviene un set en lugar de una lista en Python?
- Cuando la operación dominante es preguntar si un elemento existe. La lista recorre elemento por elemento; el set resuelve por hash en tiempo constante en promedio. Si necesitas orden o duplicados, conserva la lista y construye un set aparte para las búsquedas.
- ¿Hilos o procesos para un pipeline de OCR + LLM?
- Si el OCR corre en un subproceso y el LLM es una API remota, el trabajo es de entrada y salida, y los hilos bastan. Si el OCR corre dentro del mismo proceso de Python y satura un núcleo, usa procesos.
- ¿Cómo sé si mi script tiene una fuga de memoria?
- Toma dos fotos con tracemalloc, una al inicio y otra después de varios lotes, y compáralas. Si el mismo punto del código crece lote tras lote, ahí se están acumulando objetos.



