domingo, 25 de enero de 2026

Aprendiendo Python de 0 a experto - Debugging y Profiling

Debugging y Profiling en Python
Python // Chapter 11

Debugging y Profiling

Técnicas esenciales para encontrar errores y optimizar el rendimiento de tu código

El debugging y profiling son técnicas que van de la mano con el testing y son cruciales para dominar. Incluso cuando nuestro código es correcto, los errores sucederán. Si no los manejamos adecuadamente, pueden causar que nuestros mejores planes fallen. El debugging nos ayuda a encontrar y corregir errores, mientras que el profiling nos permite identificar cuellos de botella y optimizar el rendimiento.

En este artículo exploraremos las herramientas y técnicas fundamentales: desde técnicas de debugging con print y el módulo pdb, hasta profiling avanzado con cProfile, timeit y análisis de memoria.

DEBUG_01

Fundamentos del Debugging

El debugging es el proceso de encontrar y resolver errores (bugs) en el código. Python proporciona varias herramientas y técnicas para ayudarnos en esta tarea, desde simples declaraciones print hasta debuggers interactivos completos.

Técnicas Básicas de Debugging

PRINT DEBUGGING
Simple pero efectivo para rastrear valores de variables
LOGGING
Sistema robusto para registrar eventos y estados
DEBUGGERS
Herramientas interactivas para inspección paso a paso

Debugging con Print

La técnica más simple pero a menudo efectiva es usar declaraciones print para inspeccionar valores en puntos específicos del código:

debug_print.py
def calculate_average(numbers):
    print(f"DEBUG: Input numbers = {numbers}")
    
    total = sum(numbers)
    print(f"DEBUG: Total = {total}")
    
    count = len(numbers)
    print(f"DEBUG: Count = {count}")
    
    if count == 0:
        return 0
    
    average = total / count
    print(f"DEBUG: Average = {average}")
    
    return average

# Uso
result = calculate_average([10, 20, 30, 40])
print(f"Result: {result}")
python debug_print.py
DEBUG: Input numbers = [10, 20, 30, 40]
DEBUG: Total = 100
DEBUG: Count = 4
DEBUG: Average = 25.0
Result: 25.0

Usando el Módulo Logging

Para aplicaciones más complejas, el módulo logging es preferible a print porque permite controlar niveles de detalle y dirigir la salida a diferentes destinos:

debug_logging.py
import logging

# Configurar el logger
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

logger = logging.getLogger(__name__)

def process_data(data):
    logger.debug(f"Processing data: {data}")
    
    try:
        result = [x * 2 for x in data]
        logger.info(f"Successfully processed {len(result)} items")
        return result
    except TypeError as e:
        logger.error(f"Error processing data: {e}")
        raise

# Uso
process_data([1, 2, 3])
process_data("invalid")  # Esto generará un error
Niveles de Logging
DEBUG
INFO
WARNING
ERROR
CRITICAL
El módulo logging te permite configurar diferentes niveles de detalle. En producción, puedes establecer el nivel a WARNING para ignorar mensajes DEBUG e INFO, mientras que en desarrollo usas DEBUG para ver todo.
DEBUG_02

El Debugger PDB

Python incluye un debugger interactivo llamado pdb (Python Debugger). Con pdb puedes pausar la ejecución, inspeccionar variables, ejecutar código paso a paso y establecer breakpoints.

Iniciando el Debugger

Hay varias formas de iniciar pdb. La más moderna (Python 3.7+) es usar la función incorporada breakpoint():

debug_pdb.py
def buggy_function(items):
    result = []
    for i, item in enumerate(items):
        if i == 2:
            breakpoint()  # El debugger se activa aquí
        result.append(item * 2)
    return result

# Método alternativo (compatible con versiones anteriores)
import pdb

def another_function(x, y):
    pdb.set_trace()  # Iniciar debugger aquí
    return x + y

# Ejecutar
buggy_function([1, 2, 3, 4, 5])

Comandos Esenciales de PDB

Comando Descripción
n (next) Ejecutar la siguiente línea
s (step) Entrar en una función
c (continue) Continuar hasta el siguiente breakpoint
p <expr> Imprimir el valor de una expresión
pp <expr> Pretty-print de una expresión
l (list) Mostrar código alrededor de la línea actual
w (where) Mostrar el stack trace
b <line> Establecer un breakpoint en una línea
q (quit) Salir del debugger

Sesión Interactiva de PDB

pdb_session_example.py
def factorial(n):
    """Calcular el factorial de n."""
    if n < 0:
        raise ValueError("n debe ser no negativo")
    if n == 0:
        return 1
    return n * factorial(n - 1)

def main():
    numbers = [5, 3, -1, 7]
    results = []
    
    for num in numbers:
        breakpoint()  # Inspeccionar cada iteración
        try:
            result = factorial(num)
            results.append(result)
        except ValueError as e:
            print(f"Error: {e}")
    
    return results

if __name__ == "__main__":
    main()
> python -m pdb pdb_session_example.py
> /path/pdb_session_example.py(14)main()
-> breakpoint()
(Pdb) p num
5
(Pdb) p numbers
[5, 3, -1, 7]
(Pdb) n
> /path/pdb_session_example.py(16)main()
(Pdb) s
--Call--
> /path/pdb_session_example.py(1)factorial()
(Pdb) c
Recuerda eliminar las llamadas a breakpoint() antes de hacer deploy a producción. Puedes desactivar breakpoints globalmente estableciendo la variable de entorno PYTHONBREAKPOINT=0.
DEBUG_03

Entendiendo Tracebacks

Cuando ocurre una excepción no manejada, Python imprime un traceback. Aprender a leer tracebacks es fundamental para el debugging efectivo.

Anatomía de un Traceback

traceback_example.py
def squareroot(number):
    if number < 0:
        raise ValueError("No se permiten números negativos")
    return number ** 0.5

def quadratic(a, b, c):
    d = b**2 - 4 * a * c
    return (
        (-b - squareroot(d)) / (2 * a),
        (-b + squareroot(d)) / (2 * a)
    )

# x² + 1 = 0 (no tiene solución real)
quadratic(1, 0, 1)
python traceback_example.py
Traceback (most recent call last):
  File "traceback_example.py", line 14, in <module>
    quadratic(1, 0, 1)
    ^^^^^^^^^^^^^^^^^^
  File "traceback_example.py", line 9, in quadratic
    (-b - squareroot(d)) / (2 * a),
          ^^^^^^^^^^^^^
  File "traceback_example.py", line 3, in squareroot
    raise ValueError("No se permiten números negativos")
ValueError: No se permiten números negativos
Lectura del Traceback (De Abajo hacia Arriba)
Error: ValueError en línea 3
Llamado desde: quadratic() línea 9
Origen: módulo principal línea 14
Lee los tracebacks de abajo hacia arriba. La última línea muestra el error real, y las líneas anteriores muestran el camino a través del código que llevó al error.

Mejoras en Python 3.11+

Python 3.11 introdujo mejoras significativas en los tracebacks con indicadores precisos que subrayan exactamente qué parte de la expresión causó el error usando caracteres ^^^^.

PROFILE_01

Profiling con timeit

El módulo timeit proporciona una forma precisa de medir el tiempo de ejecución de pequeños fragmentos de código Python. Es más confiable que usar time.time() porque ejecuta el código múltiples veces y calcula promedios.

Uso Básico de timeit

timeit_basic.py
import timeit

# Comparar list comprehension vs for loop
setup = "numbers = range(1000)"

# Método 1: List comprehension
comprehension = "[x**2 for x in numbers]"

# Método 2: For loop
forloop = """
result = []
for x in numbers:
    result.append(x**2)
"""

# Ejecutar cada uno 10000 veces
time_comp = timeit.timeit(comprehension, setup, number=10000)
time_loop = timeit.timeit(forloop, setup, number=10000)

print(f"List comprehension: {time_comp:.4f}s")
print(f"For loop:           {time_loop:.4f}s")
print(f"Ratio:              {time_loop/time_comp:.2f}x más lento")
python timeit_basic.py
List comprehension: 0.7821s
For loop: 1.2534s
Ratio: 1.60x más lento

Usando timeit desde la Línea de Comandos

terminal
# Comparar métodos de concatenación de strings
$ python -m timeit -s "s=''" "for i in range(100): s += str(i)"
10000 loops, best of 5: 25.3 usec per loop

$ python -m timeit -s "parts=[]" "for i in range(100): parts.append(str(i))" "''.join(parts)"
50000 loops, best of 5: 8.2 usec per loop

Timer con Decorador Personalizado

timer_decorator.py
import functools
import time

def timer(func):
    """Decorador para medir tiempo de ejecución."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} ejecutado en {end - start:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    """Simular operación lenta."""
    total = 0
    for i in range(1000000):
        total += i ** 2
    return total

@timer
def fast_function():
    """Versión optimizada."""
    return sum(i ** 2 for i in range(1000000))

# Comparar
slow_function()
fast_function()
python timer_decorator.py
slow_function ejecutado en 0.1823s
fast_function ejecutado en 0.1456s
PROFILE_02

Profiling con cProfile

El módulo cProfile es el profiler determinístico estándar de Python. Proporciona estadísticas detalladas sobre el tiempo gastado en cada función, el número de llamadas y más.

Uso Básico de cProfile

cprofile_example.py
import cProfile
import pstats

def fibonacci(n):
    """Calcular Fibonacci de forma recursiva."""
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

def process_data():
    results = []
    for i in range(25):
        results.append(fibonacci(i))
    return results

# Perfilar la función
if __name__ == "__main__":
    profiler = cProfile.Profile()
    profiler.enable()
    
    process_data()
    
    profiler.disable()
    stats = pstats.Stats(profiler)
    stats.sort_stats('cumulative')
    stats.print_stats(10)  # Top 10 funciones
python cprofile_example.py
        242785 function calls (27 primitive calls)

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.052    0.052 cprofile_example.py:10(process_data)
242785/25    0.052    0.000    0.052    0.002 cprofile_example.py:4(fibonacci)
       25    0.000    0.000    0.000    0.000 {method 'append'}

Desde la Línea de Comandos

terminal
# Perfilar cualquier script
$ python -m cProfile -s cumulative script.py

# Guardar estadísticas en un archivo
$ python -m cProfile -o output.prof script.py

# Analizar el archivo guardado
$ python -c "import pstats; p = pstats.Stats('output.prof'); p.sort_stats('time').print_stats(10)"

Interpretando los Resultados

Columna Descripción
ncalls Número de llamadas a la función
tottime Tiempo total en la función (excluyendo subfunciones)
percall tottime dividido por ncalls
cumtime Tiempo acumulativo (incluyendo subfunciones)
filename:lineno Ubicación de la función
El ratio 242785/25 en ncalls indica llamadas recursivas: 242785 llamadas totales, pero solo 25 fueron llamadas "primitivas" (no recursivas).
PROFILE_03

Técnicas de Optimización

Una vez identificados los cuellos de botella con profiling, podemos aplicar técnicas de optimización. La clave es optimizar solo lo que importa y medir siempre antes y después.

Memoización

La memoización almacena resultados de funciones costosas para evitar recálculos:

memoization.py
from functools import lru_cache
import timeit

# Fibonacci sin memoización
def fib_naive(n):
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

# Fibonacci con memoización usando lru_cache
@lru_cache(maxsize=None)
def fib_memo(n):
    if n < 2:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

# Comparar rendimiento
n = 30

time_naive = timeit.timeit(
    f"fib_naive({n})",
    globals=globals(),
    number=1
)

time_memo = timeit.timeit(
    f"fib_memo({n})",
    globals=globals(),
    number=1
)

print(f"Sin memoización: {time_naive:.4f}s")
print(f"Con memoización: {time_memo:.6f}s")
print(f"Speedup: {time_naive/time_memo:.0f}x")
python memoization.py
Sin memoización: 0.2847s
Con memoización: 0.000012s
Speedup: 23725x
Flujo de Optimización
Medir
Identificar
Optimizar
Verificar

Comparación de Estructuras de Datos

data_structures_comparison.py
import timeit

# Búsqueda en lista vs set
setup_list = "data = list(range(10000))"
setup_set = "data = set(range(10000))"

search = "9999 in data"

time_list = timeit.timeit(search, setup_list, number=10000)
time_set = timeit.timeit(search, setup_set, number=10000)

print("Búsqueda de elemento:")
print(f"  Lista: {time_list:.4f}s")
print(f"  Set:   {time_set:.4f}s")
print(f"  Set es {time_list/time_set:.0f}x más rápido")

# Concatenación de strings
print("\nConcatenación de strings:")

concat_plus = """
s = ''
for i in range(1000):
    s += str(i)
"""

concat_join = """
parts = []
for i in range(1000):
    parts.append(str(i))
s = ''.join(parts)
"""

time_plus = timeit.timeit(concat_plus, number=1000)
time_join = timeit.timeit(concat_join, number=1000)

print(f"  += operator: {time_plus:.4f}s")
print(f"  join():      {time_join:.4f}s")
print(f"  join() es {time_plus/time_join:.1f}x más rápido")
python data_structures_comparison.py
Búsqueda de elemento:
  Lista: 0.8234s
  Set: 0.0012s
  Set es 686x más rápido

Concatenación de strings:
  += operator: 0.1456s
  join(): 0.0523s
  join() es 2.8x más rápido
Regla de oro: "La optimización prematura es la raíz de todo mal" - Donald Knuth. Siempre perfila tu código primero para identificar los verdaderos cuellos de botella antes de optimizar.
PROFILE_04

Memory Profiling

Además del tiempo de ejecución, el uso de memoria es crucial para aplicaciones que manejan grandes volúmenes de datos. Python proporciona herramientas para analizar el consumo de memoria.

Usando sys.getsizeof()

memory_basic.py
import sys

# Comparar tamaños de diferentes estructuras
data_list = [i for i in range(1000)]
data_tuple = tuple(i for i in range(1000))
data_gen = (i for i in range(1000))

print("Tamaño de estructuras con 1000 elementos:")
print(f"  Lista:     {sys.getsizeof(data_list):>8} bytes")
print(f"  Tupla:     {sys.getsizeof(data_tuple):>8} bytes")
print(f"  Generator: {sys.getsizeof(data_gen):>8} bytes")

# Tamaño de diferentes tipos
print("\nTamaño de tipos básicos:")
print(f"  int (0):      {sys.getsizeof(0)} bytes")
print(f"  int (2^30):   {sys.getsizeof(2**30)} bytes")
print(f"  float:        {sys.getsizeof(3.14)} bytes")
print(f"  str vacío:    {sys.getsizeof('')} bytes")
print(f"  str 10 chars: {sys.getsizeof('a'*10)} bytes")
print(f"  dict vacío:   {sys.getsizeof({})} bytes")
python memory_basic.py
Tamaño de estructuras con 1000 elementos:
  Lista: 8856 bytes
  Tupla: 8040 bytes
  Generator: 200 bytes

Tamaño de tipos básicos:
  int (0): 28 bytes
  int (2^30): 32 bytes
  float: 24 bytes
  str vacío: 49 bytes
  str 10 chars: 59 bytes
  dict vacío: 64 bytes

Generadores vs Listas para Memoria

generators_vs_lists.py
import sys

def process_with_list(n):
    """Procesar creando lista completa en memoria."""
    data = [x ** 2 for x in range(n)]
    return sum(data)

def process_with_generator(n):
    """Procesar usando generador - memoria constante."""
    data = (x ** 2 for x in range(n))
    return sum(data)

# Ambas funciones dan el mismo resultado
n = 1000000
print(f"Con lista:     {process_with_list(n)}")
print(f"Con generador: {process_with_generator(n)}")

# Pero el uso de memoria es muy diferente
list_data = [x ** 2 for x in range(n)]
gen_data = (x ** 2 for x in range(n))

print(f"\nMemoria lista:     {sys.getsizeof(list_data) / 1024 / 1024:.2f} MB")
print(f"Memoria generador: {sys.getsizeof(gen_data)} bytes")
python generators_vs_lists.py
Con lista: 333332833333500000
Con generador: 333332833333500000

Memoria lista: 8.00 MB
Memoria generador: 200 bytes
Los generadores son excelentes para procesar grandes conjuntos de datos porque mantienen un uso de memoria constante independientemente del tamaño de los datos.

Resumen de Técnicas

01 // Print & Logging

Técnicas básicas para rastrear valores y estados del programa durante la ejecución.

02 // PDB Debugger

Debugger interactivo para inspección paso a paso, breakpoints y análisis de stack.

03 // Tracebacks

Leer y entender los mensajes de error para localizar rápidamente la fuente de problemas.

04 // timeit

Medición precisa del tiempo de ejecución para comparar diferentes implementaciones.

05 // cProfile

Profiler completo para identificar funciones costosas y cuellos de botella.

06 // Memory Profiling

Análisis del uso de memoria para optimizar el consumo de recursos.

Basado en "Learn Python Programming" 4th Edition // Capítulo 11: Debugging and Profiling

Fabrizio Romano & Heinrich Kruger // Packt Publishing