Capítulo 6 de 14 10 secciones 11 min

Cuando algo falla: errores y depuración

Los errores que de verdad salen con datos, cómo atraparlos sin taparlos, y cómo encontrar el problema sin volverte loca.

En Python los errores se atrapan con try y except. La regla que más problemas evita es atrapar solo el error concreto que esperas (except ValueError) y nunca usar un except vacío que se traga todo, porque eso convierte un fallo ruidoso en datos silenciosamente equivocados.

Hola! Vamos a hacernos amigas de los errores

Te lo dije en el capítulo 1 y lo repito porque importa: los errores no son un castigo, son información 💜

Un error que revienta es el mejor de los casos. El peligroso es el que no revienta y te deja un número mal, que es del que va buena parte de este capítulo.

Los seis errores que de verdad vas a ver

ErrorQué significaCuándo aparece con datos
TypeErrorLe pediste algo imposible a ese tipoSumar un texto con un número
ValueErrorEl tipo está bien, el valor noConvertir 'n/a' a número
KeyErrorEsa clave no existeUna columna que no vino en el archivo
IndexErrorEsa posición no existeUna fila con menos campos de los esperados
FileNotFoundErrorNo está el archivoLa ruta, siempre la ruta
ZeroDivisionErrorDivisión entre ceroUn promedio de una lista vacía

Vamos a verlos de verdad, que es como se aprenden:

int('n/a')
ValueError: invalid literal for int() with base 10: 'n/a'
venta = {'ciudad': 'Lima'}
venta['descuento']
KeyError: 'descuento'
sum([]) / len([])
ZeroDivisionError: division by zero

Fíjate en la diferencia entre los dos primeros. int('12') funciona, o sea que int() sí acepta textos: el tipo estaba bien y lo que estaba mal era el valor. Eso es un ValueError.

En cambio '12' + 5 es un TypeError, porque la operación misma no tiene sentido entre esos dos tipos.

try y except

valores = ['1250.50', '480,37', 'n/a', '95.5', '']

limpios = []
descartados = []

for v in valores:
    try:
        limpios.append(float(v))
    except ValueError:
        descartados.append(v)

print('limpios     :', limpios)
print('descartados :', descartados)
limpios     : [1250.5, 95.5]
descartados : ['480,37', 'n/a', '']

Eso ya es código útil de verdad: procesa lo que puede y guarda lo que no pudo para mirarlo después.

Y mira qué se ve ahí: '480,37' no era basura, era un número con coma decimal. Si lo hubiera botado sin mirar, habría perdido una venta buena. Por eso los descartes se guardan, no se ignoran 🔍

for v in descartados:
    print(repr(v), '->', float(v.replace(',', '.')) if v.replace(',', '').replace('.', '').isdigit() else 'sin arreglo')
'480,37' -> 480.37
'n/a' -> sin arreglo
'' -> sin arreglo

El anti-patrón que más daño hace

Esto lo vas a ver en internet y en código de gente con años de experiencia. No lo copies.

valores = ['1250.50', 'n/a', '95.5']
total = 0

for v in valores:
    try:
        total = total + float(v)
    except:
        pass

print(total)
1346.0

Funciona. Ese es justo el problema 😖

Un except pelado se traga todo: el valor inválido que esperabas, pero también un error de tipeo tuyo, un problema de memoria, una interrupción con Ctrl+C. Y pass significa "no hagas nada al respecto".

Entonces un día tu proceso devuelve un total que es la mitad del real y no hay ni una línea en ningún lado que diga qué pasó. Lo he visto costar semanas de confianza en un equipo de datos.

Las dos reglas, y son duras:

  • 🎯 Atrapa el error concreto que esperas: except ValueError, no except a secas.
  • 📢 Haz algo con él. Guárdalo, cuéntalo, imprímelo. Nunca pass.
valores = ['1250.50', 'n/a', '95.5']
total = 0
problemas = []

for v in valores:
    try:
        total = total + float(v)
    except ValueError as e:
        problemas.append((v, str(e)))

print(f'Total: {total}')
print(f'Problemas: {len(problemas)}')
for valor, mensaje in problemas:
    print(f'  {repr(valor)}: {mensaje}')
Total: 1346.0
Problemas: 1
  'n/a': could not convert string to float: 'n/a'

El mismo total, pero ahora sabes que hubo un problema y cuál fue. Esa diferencia es la que separa un script de un proceso en el que se puede confiar 🌸

else y finally

def leer_monto(texto):
    try:
        valor = float(texto)
    except ValueError:
        print(f'  {repr(texto)} no es un numero')
        return None
    else:
        print(f'  {repr(texto)} convertido bien')
        return valor
    finally:
        print('  (esto sale siempre)')

print('Caso bueno:')
leer_monto('480.37')
print('Caso malo:')
leer_monto('n/a')
Caso bueno:
  '480.37' convertido bien
  (esto sale siempre)
Caso malo:
  'n/a' no es un numero
  (esto sale siempre)
  • else corre solo si no hubo error.
  • 🔒 finally corre siempre, con error o sin él, incluso si hay un return en medio. Es donde se cierran archivos y conexiones.

Levantar tus propios errores

A veces el error no lo produce Python:

  • Lo produces tú
  • Porque los datos no tienen sentido
  • Seguir sería peor
def calcula_descuento(monto, porcentaje):
    if not 0 <= porcentaje <= 1:
        raise ValueError(
            f'El porcentaje debe ir entre 0 y 1, y llegó {porcentaje}. '
            f'Si viene como 15 en vez de 0.15, divídelo entre 100.')
    return round(monto * porcentaje, 2)

print(calcula_descuento(1000, 0.15))
calcula_descuento(1000, 15)
ValueError: El porcentaje debe ir entre 0 y 1, y llegó 15. Si viene como 15 en vez de 0.15, divídelo entre 100.

Sin ese raise, el descuento de 15 habría dado S/15.000 sobre una venta de S/1.000. Habría corrido perfecto y el reporte habría salido absurdo.

Un buen mensaje de error dice tres cosas: qué se esperaba, qué llegó, y qué hacer al respecto. Ese de arriba dice las tres 💜

Cómo depurar sin volverte loca

Cuando algo no funciona y no entiendes por qué, este es el orden que a mí me funciona.

1. Imprime el tipo antes que el valor.

dato = '1250.50'

print(type(dato), repr(dato))
<class 'str'> '1250.50'

El repr es clave: te muestra las comillas y los espacios invisibles. La mitad de los misterios se resuelven aquí.

2. Parte el problema en pedazos. Si una línea larga falla, pártela y mira cada trozo.

fila = '  C0045 ; Arequipa ; 480,37  '

paso1 = fila.strip()
paso2 = paso1.split(';')
paso3 = [p.strip() for p in paso2]

print(repr(paso1))
print(paso2)
print(paso3)
print(float(paso3[2].replace(',', '.')))
'C0045 ; Arequipa ; 480,37'
['C0045 ', ' Arequipa ', ' 480,37']
['C0045', 'Arequipa', '480,37']
480.37

Ahí se ve clarito que después de partir por punto y coma quedaban espacios, y que por eso hacía falta el segundo strip.

3. Reduce el caso. Si falla con tres mil filas, prueba con la primera. Casi siempre falla también, y depurar una fila es mil veces más rápido que depurar tres mil.

4. Lee el traceback de abajo hacia arriba y busca la última línea que menciona tu código. Eso ya lo tienes del capítulo 1.

5. Cuando nada funcione, explícaselo en voz alta a alguien. Te vas a dar cuenta sola a mitad de la explicación. A mí me pasa siempre, y no tengo idea de por qué funciona tan bien 😄

Un procesador de archivo que aguanta datos feos

filas = [
    'C0045;Arequipa;480.37',
    'C0325;Lima;524,90',
    'C0260;Cusco;n/a',
    'C0111;Piura',
    'C0777;Trujillo;1200.00',
]

buenas = []
rechazadas = []

for numero, fila in enumerate(filas, start=1):
    partes = fila.split(';')

    if len(partes) != 3:
        rechazadas.append((numero, fila, 'no tiene 3 campos'))
        continue

    codigo, ciudad, monto_texto = partes
    try:
        monto = float(monto_texto.replace(',', '.'))
    except ValueError:
        rechazadas.append((numero, fila, f'monto ilegible: {monto_texto}'))
        continue

    buenas.append({'codigo': codigo, 'ciudad': ciudad, 'monto': monto})

print(f'{len(buenas)} filas buenas, {len(rechazadas)} rechazadas')
print(f'Total: S/{sum(b["monto"] for b in buenas):,.2f}')
print()
for numero, fila, motivo in rechazadas:
    print(f'  fila {numero}: {motivo}')
3 filas buenas, 2 rechazadas
Total: S/2,205.27

  fila 3: monto ilegible: n/a
  fila 4: no tiene 3 campos

Eso es lo que quiero que te lleves de este capítulo. No es que no falle: es que cuando falla, te dice qué fila y por qué, y sigue con las demás.

Ese informe de rechazos es lo que le mandas a quien te pasó el archivo. Y es la diferencia entre "tu archivo tiene errores" y "las filas 3 y 4 tienen esto, ¿me las puedes corregir?" 🌸

Ejercicios

1. Reconoce el error

Provoca un ValueError y un TypeError, y explica en qué se diferencian.

try:
    int('sin dato')          # la celda que alguien dejo escrita a mano
except ValueError as e:
    print('ValueError:', e)

try:
    '12' + 5                 # unidades como texto mas un numero
except TypeError as e:
    print('TypeError:', e)
ValueError: invalid literal for int() with base 10: 'sin dato'
TypeError: can only concatenate str (not "int") to str

En el primero int() sí acepta textos, este texto en particular no servía. En el segundo la operación misma no existe entre esos tipos.

2. Convertir sin morir

Convierte una lista de textos a número, saltándote los que no se puedan, y cuenta cuántos se salvaron.

montos = ['100', '250.5', 'n/a', '', '1200']    # como llegan del sistema

numeros = []
for m in montos:
    try:
        numeros.append(float(m))
    except ValueError:
        pass

print(numeros, f'{len(numeros)} de {len(montos)} montos legibles')
[100.0, 250.5, 1200.0] 3 de 5 montos legibles

Aquí el pass está bien porque la línea de abajo cuenta cuántos se perdieron. El anti-patrón es el pass que no deja rastro de nada.

3. El except pelado, y por qué no

Escribe un except vacío que tape un error de tipeo tuyo, para ver lo fácil que es.

montos = ['10', '20']
total = 0

for m in montos:
    try:
        total = total + flotar(m)     # esta funcion no existe
    except:
        pass

print('Total de la caja del día:', total)
Total de la caja del día: 0

Cero, sin una sola queja. El except pelado se tragó un NameError que no tenía nada que ver con los datos. Con except ValueError habría reventado y te habrías enterado.

4. Clave que puede no venir

Recorre una lista de ventas donde algunas no traen canal y arma el conteo por canal sin que reviente.

ventas = [
    {'ciudad': 'Lima', 'canal': 'Web'},
    {'ciudad': 'Cusco'},
    {'ciudad': 'Lima', 'canal': 'Web'},
]

conteo = {}
for v in ventas:
    canal = v.get('canal', 'Sin canal')
    conteo[canal] = conteo.get(canal, 0) + 1

print(conteo)
{'Web': 2, 'Sin canal': 1}

Aquí ni hizo falta el try: .get() con valor por defecto lo resuelve más limpio. Cuando exista una forma de prevenir el error, es mejor que atraparlo.

5. Tu propio raise

Escribe una función que rechace unidades negativas con un mensaje que explique qué hacer.

def registra_venta(unidades):
    if unidades < 0:
        raise ValueError(
            f'Las unidades no pueden ser negativas y llegaron {unidades}. '
            f'Si es una devolución, va en el campo de notas de crédito.')
    return f'{unidades} unidades registradas'

print(registra_venta(10))
registra_venta(-3)
ValueError: Las unidades no pueden ser negativas y llegaron -3. Si es una devolución, va en el campo de notas de crédito.
6. La división que a veces es cero

Calcula el promedio de una lista que puede venir vacía, de dos formas: con try y con if.

def ticket_try(montos):
    try:
        return sum(montos) / len(montos)
    except ZeroDivisionError:
        return 0

def ticket_if(montos):
    if not montos:
        return 0
    return sum(montos) / len(montos)

# El filtro de una ciudad que ese dia no vendio nada
print(ticket_try([]), ticket_if([]))
print(ticket_try([10, 20]), ticket_if([10, 20]))
0 0
15.0 15.0

Las dos valen. La del if se lee mejor porque el caso vacío no es excepcional, es normal.

7. Depurar con repr

Tienes dos códigos de cliente que parecen iguales y no lo son. Encuentra la diferencia.

a = 'C0045'
b = 'C0045 '

print(a == b)
print(repr(a), repr(b))
print(len(a), len(b))
False
'C0045' 'C0045 '
5 6

Un espacio al final. Con print normal es invisible y te puede costar una tarde de "pero si son iguales" 🙃

8. Contar los rechazos por motivo

Procesa una lista de filas y arma un conteo de rechazos agrupado por motivo.

montos = ['100', 'n/a', '', '250', 'abc']    # cinco filas del archivo

motivos = {}
for f in montos:
    if f == '':
        motivos['vacío'] = motivos.get('vacío', 0) + 1
        continue
    try:
        float(f)
    except ValueError:
        motivos['no numérico'] = motivos.get('no numérico', 0) + 1

print(motivos)
{'no numérico': 2, 'vacío': 1}

Este conteo por motivo es lo primero que te van a pedir cuando digas "el archivo tiene problemas". Sin él, la conversación no avanza.

Lo que te llevas

  • 🎯 Atrapa el error concreto, nunca except a secas.
  • 📢 Nunca pass sin dejar rastro: cuenta, guarda o imprime.
  • 🛑 Un raise tuyo, a tiempo, evita un reporte absurdo.
  • 🔍 repr() y type() resuelven la mitad de los misterios.
  • 📋 Un buen proceso no es el que no falla, es el que dice qué fila falló y por qué.

En el capítulo 7 salimos del cuaderno: módulos, import, y los entornos virtuales, que son la respuesta a "en mi máquina funcionaba".

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?