Defer, panic y recover en Go: cuándo usarlos y cuándo evitarlos

Defer para limpieza, panic para fallos excepcionales y recover para contención. Cuándo tienen sentido y cuándo no.

Cover for Defer, panic y recover en Go: cuándo usarlos y cuándo evitarlos

Defer, panic y recover son tres mecanismos que los recién llegados a Go ignoran o abusan. Las dos cosas están mal. Si los ignoras, escribes código frágil que filtra recursos. Si los abusas, escribes Go con mentalidad de Java o Python, convirtiendo panic/recover en un try/catch disfrazado que el lenguaje nunca pretendió ofrecer.

La cuestión es que estos tres mecanismos están diseñados para cosas muy concretas. defer es para limpieza garantizada. panic es para fallos que no deberían ocurrir. recover es para contención en fronteras. Fuera de esos contextos, usarlos es un error de diseño.

Si vienes de lenguajes con excepciones, necesitas recalibrar tu instinto. En Go, el camino correcto para la mayoría de errores es devolverlos como valores. Si no tienes claro esto, te recomiendo leer primero errores en Go antes de continuar aquí.


Defer: limpieza garantizada

defer pospone la ejecución de una función hasta que la función que la contiene retorne. No importa cómo retorne: por un return normal, por un panic, por llegar al final del cuerpo. La función diferida siempre se ejecuta.

func readConfig(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("abrir config: %w", err)
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        return nil, fmt.Errorf("leer config: %w", err)
    }
    return data, nil
}

El defer f.Close() se ejecuta cuando readConfig retorna, ya sea con datos o con error. No tienes que recordar cerrar el fichero antes de cada return. No puedes olvidarte. Esa es la gracia.

Esto es especialmente valioso cuando una función tiene múltiples puntos de retorno. Sin defer, tendrías que cerrar el recurso antes de cada return, lo cual es una receta para bugs. Con defer, lo declaras una vez justo después de adquirir el recurso y te olvidas.

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // Primer punto de retorno
    header, err := readHeader(f)
    if err != nil {
        return fmt.Errorf("header inválido: %w", err)
    }

    // Segundo punto de retorno
    if header.Version < 3 {
        return fmt.Errorf("versión %d no soportada", header.Version)
    }

    // Tercer punto de retorno
    return processBody(f, header)
}

Tres puntos de retorno, un solo defer. No hay forma de que el fichero quede abierto.


Orden de ejecución: pila LIFO

Cuando usas varios defer en la misma función, se ejecutan en orden inverso: último en entrar, primero en salir. Es una pila.

func ejemplo() {
    fmt.Println("inicio")

    defer fmt.Println("primero diferido")
    defer fmt.Println("segundo diferido")
    defer fmt.Println("tercero diferido")

    fmt.Println("fin")
}

La salida:

inicio
fin
tercero diferido
segundo diferido
primero diferido

Esto no es arbitrario. Tiene sentido práctico: si adquieres recursos en orden A, B, C, normalmente quieres liberarlos en orden C, B, A. Es el mismo patrón que un destructor en C++ o un bloque finally anidado.

func transaccion(db *sql.DB) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback() // Se ejecuta segundo si hay error

    conn, err := acquireExternalConnection()
    if err != nil {
        return err
    }
    defer conn.Release() // Se ejecuta primero

    // ... operaciones ...

    return tx.Commit() // Rollback es no-op después de Commit
}

El conn.Release() se ejecuta antes que tx.Rollback(), lo cual es exactamente lo que quieres: primero liberas la conexión externa, luego deshaces la transacción si fue necesario.


Patrones comunes con defer

Cerrar ficheros y conexiones

El caso más básico y el más frecuente. Cualquier recurso que implementa io.Closer se cierra con defer.

resp, err := http.Get("https://api.example.com/data")
if err != nil {
    return err
}
defer resp.Body.Close()

Un matiz importante: el defer va después de comprobar el error. Si http.Get falla, resp puede ser nil y llamar a resp.Body.Close() provocaría un panic. Siempre sigue el patrón: adquirir, comprobar error, diferir cierre.

Liberar locks

Los mutex y otros mecanismos de sincronización siguen el mismo patrón.

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    val, ok := c.data[key]
    return val, ok
}

Aquí el defer es imprescindible. Sin él, cualquier panic dentro de la sección crítica dejaría el mutex bloqueado, causando un deadlock que es extremadamente difícil de diagnosticar en producción.

Medir duración

Un patrón elegante para instrumentar funciones.

func measureTime(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s tardó %v\n", name, time.Since(start))
    }
}

func operacionLenta() {
    defer measureTime("operacionLenta")()
    // Nota los paréntesis dobles: measureTime se ejecuta inmediatamente,
    // y la función que devuelve se difiere.

    time.Sleep(2 * time.Second)
}

Esto funciona porque measureTime("operacionLenta") se evalúa en el momento del defer (capturando el tiempo de inicio), pero la función que devuelve se ejecuta al salir de operacionLenta (calculando la duración).

Recover de panics (lo veremos en detalle más adelante)

defer func() {
    if r := recover(); r != nil {
        log.Printf("panic recuperado: %v", r)
    }
}()

Trampas de defer que te van a morder

Los argumentos se evalúan inmediatamente

Los argumentos de la función diferida se evalúan en el momento en que se ejecuta la sentencia defer, no cuando se ejecuta la función diferida.

func ejemplo() {
    x := 0
    defer fmt.Println("x vale:", x) // x se evalúa AHORA, vale 0

    x = 42
    fmt.Println("x modificado a:", x)
}

Salida:

x modificado a: 42
x vale: 0

No imprime 42. Imprime 0, porque ese era el valor de x cuando el defer se registró. Si necesitas capturar el valor final, usa un closure:

func ejemplo() {
    x := 0
    defer func() {
        fmt.Println("x vale:", x) // Ahora sí, captura la variable
    }()

    x = 42
}

Ahora imprime 42, porque el closure captura la variable, no su valor.

Defer en bucles: cuidado con la acumulación

Un error clásico de principiante:

func procesarFicheros(paths []string) error {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close() // MAL: se acumulan hasta que procesarFicheros retorne

        // ... procesar fichero ...
    }
    return nil
}

Si paths tiene 10.000 elementos, vas a tener 10.000 ficheros abiertos simultáneamente antes de que se cierre ninguno. Los defer no se ejecutan al final de cada iteración del bucle, sino al final de la función.

La solución es extraer el cuerpo del bucle a una función separada:

func procesarFicheros(paths []string) error {
    for _, path := range paths {
        if err := procesarUnFichero(path); err != nil {
            return err
        }
    }
    return nil
}

func procesarUnFichero(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // ... procesar fichero ...
    return nil
}

Ahora el defer se ejecuta al final de cada llamada a procesarUnFichero. Limpio, correcto, sin filtraciones.

Defer y valores de retorno con nombre

defer puede modificar los valores de retorno con nombre. Esto es útil pero fácil de malinterpretar.

func leerContenido(path string) (content string, err error) {
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr // Modifica el error de retorno
        }
    }()

    data, err := io.ReadAll(f)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

El defer aquí modifica err (el valor de retorno con nombre) si f.Close() falla y no había un error previo. Esto es el patrón correcto para no perder errores de cierre, pero requiere que entiendas que los closures en defer acceden a los valores de retorno con nombre por referencia.


Panic: cuando el programa no puede continuar

panic detiene la ejecución normal de la goroutine actual. Ejecuta los defer pendientes y luego propaga el panic hacia arriba en la pila de llamadas hasta que alcanza el tope de la goroutine, donde termina el programa con un stack trace.

func dividir(a, b int) int {
    if b == 0 {
        panic("división por cero")
    }
    return a / b
}

Un panic es ruidoso. Imprime un stack trace completo. Mata el proceso si no se recupera. Y eso es intencional. Es una señal de que algo está fundamentalmente mal.

La librería estándar de Go usa panic internamente en situaciones como:

  • Acceso fuera de los límites de un slice
  • Assertion de tipo incorrecta sin la forma de dos valores
  • Envío a un channel cerrado
  • Uso de un mutex después de copiarlo

Todas estas son errores del programador, no errores operacionales. Esa distinción es clave.


Cuándo panic es apropiado

Errores de inicialización irrecuperables

Si tu programa no puede arrancar sin una configuración válida, un panic en el inicio es razonable.

func mustLoadConfig(path string) Config {
    data, err := os.ReadFile(path)
    if err != nil {
        panic(fmt.Sprintf("no se puede leer config %s: %v", path, err))
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        panic(fmt.Sprintf("config inválida en %s: %v", path, err))
    }

    return cfg
}

El prefijo must es una convención en Go. Funciones como template.Must, regexp.MustCompile o sql.MustOpen hacen panic si fallan. La idea es que solo las usas en la inicialización del programa, donde un fallo significa que no hay nada que hacer.

Invariantes del programador

Si una condición debería ser imposible según la lógica del programa y se viola, un panic es apropiado.

func estadoSiguiente(actual Estado) Estado {
    switch actual {
    case Pendiente:
        return EnProceso
    case EnProceso:
        return Completado
    case Completado:
        return Archivado
    default:
        panic(fmt.Sprintf("estado desconocido: %d", actual))
    }
}

Si llegas al default, es un bug en tu código, no un error del usuario ni un fallo de red. Un panic con un mensaje claro es mejor que devolver un error que nadie va a manejar correctamente porque “no debería pasar”.

Detección de bugs en desarrollo

A veces usas panic como una assertion durante desarrollo para detectar problemas rápido.

func newBuffer(size int) *Buffer {
    if size <= 0 {
        panic("newBuffer: size debe ser positivo")
    }
    return &Buffer{data: make([]byte, 0, size)}
}

Esto es aceptable si newBuffer solo se llama con valores controlados por el programador, no con input del usuario.


Cuándo panic NO es apropiado

Aquí es donde la mayoría de gente se equivoca. Venimos de lenguajes donde throw new Exception("algo salió mal") es normal, y la tentación de usar panic de la misma forma es enorme. Resiste.

Errores de validación

// MAL
func crearUsuario(nombre string) Usuario {
    if nombre == "" {
        panic("nombre vacío") // NO
    }
    return Usuario{Nombre: nombre}
}

// BIEN
func crearUsuario(nombre string) (Usuario, error) {
    if nombre == "" {
        return Usuario{}, errors.New("el nombre no puede estar vacío")
    }
    return Usuario{Nombre: nombre}, nil
}

Un nombre vacío no es un fallo irrecuperable. Es un input inválido. Devuelve un error.

Datos no encontrados

// MAL
func buscarProducto(id int) Producto {
    p, ok := productos[id]
    if !ok {
        panic(fmt.Sprintf("producto %d no encontrado", id))
    }
    return p
}

// BIEN
func buscarProducto(id int) (Producto, error) {
    p, ok := productos[id]
    if !ok {
        return Producto{}, fmt.Errorf("producto %d no encontrado", id)
    }
    return p, nil
}

Que un producto no exista es un escenario operacional completamente normal. Ni se te ocurra hacer panic por eso.

Fallos de red, I/O, base de datos

// MAL
func obtenerDatos(url string) []byte {
    resp, err := http.Get(url)
    if err != nil {
        panic(err) // NO, las redes fallan constantemente
    }
    defer resp.Body.Close()
    data, _ := io.ReadAll(resp.Body)
    return data
}

Las redes fallan. Los discos fallan. Las bases de datos se saturan. Estos son errores esperados que tu programa debe manejar con gracia, no crashear.

Timeouts y cancelaciones

// MAL
func hacerPeticion(ctx context.Context) Resultado {
    resultado, err := servicio.Llamar(ctx)
    if err != nil {
        panic(err) // NO
    }
    return resultado
}

Un timeout es una decisión operacional. Un context cancelado es un flujo normal. Si quieres profundizar en cómo manejar contextos correctamente, he escrito sobre context en Go.

La regla es simple: si el error puede ocurrir en producción como parte del funcionamiento normal del sistema, nunca uses panic.


Recover: capturar panics en las fronteras

recover es una función builtin que captura un panic en curso y devuelve el valor que se pasó a panic. Solo funciona dentro de una función diferida. Fuera de un defer, siempre devuelve nil.

func operacionSegura() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recuperado: %v", r)
        }
    }()

    // Código que podría hacer panic
    funcionPeligrosa()

    return nil
}

recover no es un mecanismo general de manejo de errores. Es un mecanismo de contención para evitar que un panic descontrolado mate tu proceso entero. La distinción es importante.

Dónde tiene sentido recover

recover tiene sentido en fronteras:

  1. Fronteras de goroutines: Si lanzas goroutines que procesan trabajo, un panic en una goroutine mata todo el programa. Un recover en la goroutine puede registrar el error y continuar procesando el siguiente trabajo.

  2. Fronteras de request: En un servidor HTTP, un panic en un handler no debería tumbar el servidor entero.

  3. Fronteras de plugin: Si tu programa carga código de terceros o ejecuta lógica configurable, un recover protege tu proceso principal.

  4. Fronteras de librería pública: Si internamente usas panic para simplificar flujo, tu API pública no debe exponerlo.


El patrón del servidor HTTP: middleware de recover

Este es probablemente el uso más común y más legítimo de recover en producción. Un servidor HTTP que sirve miles de requests no puede permitirse que un bug en un handler mate el proceso entero.

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // Registrar el stack trace para debugging
                stack := debug.Stack()
                log.Printf(
                    "PANIC en %s %s: %v\n%s",
                    r.Method, r.URL.Path, rec, stack,
                )

                // Devolver 500 al cliente
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte("Internal Server Error"))
            }
        }()

        next.ServeHTTP(w, r)
    })
}

Y lo usas así:

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /users/{id}", getUser)
    mux.HandleFunc("POST /orders", createOrder)

    handler := recoveryMiddleware(mux)

    log.Fatal(http.ListenAndServe(":8080", handler))
}

Ahora, si getUser tiene un bug que provoca un panic (acceso a un slice fuera de rango, nil pointer dereference, lo que sea), el middleware captura el panic, registra el stack trace para que puedas debuggear, devuelve un 500 al cliente y el servidor sigue funcionando para el resto de peticiones.

Frameworks como Gin ya incluyen este middleware por defecto. Si estás construyendo con la librería estándar, necesitas añadirlo tú. Más detalles sobre esto en middlewares en Go y API REST con Go.

Recover en goroutines de trabajo

Otro patrón importante: workers que procesan tareas de una cola.

func worker(id int, tareas <-chan Tarea) {
    for tarea := range tareas {
        procesarConRecover(id, tarea)
    }
}

func procesarConRecover(workerID int, tarea Tarea) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf(
                "Worker %d: panic procesando tarea %s: %v\n%s",
                workerID, tarea.ID, r, debug.Stack(),
            )
            // Opcionalmente: enviar a una dead-letter queue
        }
    }()

    tarea.Procesar()
}

Sin el recover, un panic en tarea.Procesar() mataría la goroutine del worker, dejando trabajo sin procesar. Con el recover, el worker registra el error y continúa con la siguiente tarea.

Fíjate en que procesarConRecover es una función separada. No puedes poner el defer/recover dentro de la función anónima del goroutine y esperar que se recupere de panics en funciones que llamas desde ahí. Bueno, sí puedes, pero es más limpio separarlo para que el scope sea claro.


Anti-pattern: usar panic/recover como try/catch

Este es el error más grave y el más común entre programadores que vienen de Java, C# o Python. Vamos a verlo explícitamente para que quede claro.

El anti-pattern

// NO HAGAS ESTO
type ValidationError struct {
    Field   string
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func validarPedido(p Pedido) {
    if p.Total <= 0 {
        panic(ValidationError{Field: "total", Message: "debe ser positivo"})
    }
    if p.ClienteID == "" {
        panic(ValidationError{Field: "cliente_id", Message: "es obligatorio"})
    }
}

func crearPedido(p Pedido) error {
    defer func() {
        if r := recover(); r != nil {
            if ve, ok := r.(ValidationError); ok {
                // "Capturamos" la validación como si fuera un catch
                log.Printf("validación fallida: %v", ve)
            }
        }
    }()

    validarPedido(p)
    // ... guardar en base de datos ...
    return nil
}

Esto funciona. Pero es terrible. Estás usando panic como throw y recover como catch. Estás ocultando el flujo de errores. Estás haciendo imposible que el llamador sepa qué puede fallar mirando la firma de la función. Estás rompiendo el contrato social de Go.

La versión correcta

func validarPedido(p Pedido) error {
    if p.Total <= 0 {
        return fmt.Errorf("total debe ser positivo, tiene %f", p.Total)
    }
    if p.ClienteID == "" {
        return errors.New("cliente_id es obligatorio")
    }
    return nil
}

func crearPedido(p Pedido) error {
    if err := validarPedido(p); err != nil {
        return fmt.Errorf("pedido inválido: %w", err)
    }

    // ... guardar en base de datos ...
    return nil
}

Más líneas, sí. Pero cada función declara exactamente qué puede fallar. El llamador sabe que crearPedido puede devolver un error. No hay sorpresas, no hay magia, no hay stack unwinding invisible.

Por qué el anti-pattern es dañino

  1. Rendimiento: panic/recover es significativamente más lento que devolver un error. No es gratis; implica unwinding del stack.

  2. Legibilidad: Cuando lees una función que devuelve error, sabes inmediatamente que puede fallar. Cuando lees una función que hace panic, no tienes ni idea de si alguien la está recuperando arriba o si va a matar el proceso.

  3. Composición: Los errores en Go se pueden wrappear con %w, inspeccionar con errors.Is y errors.As, y manejar de forma granular. Un panic es un hacha: lo atrapas todo o no atrapas nada.

  4. Convención: Todo el ecosistema de Go espera errores como valores. Si tu librería hace panic para errores operacionales, nadie querrá usarla.

Si te encuentras escribiendo recover fuera de un middleware o un boundary, probablemente estás haciendo algo mal.


Caso especial: panic interno con recover en la API pública

Hay un caso donde usar panic/recover internamente es aceptable y la propia librería estándar lo hace: cuando tienes recursión profunda o un parser complejo y necesitas salir de muchos niveles de anidamiento de golpe.

El paquete encoding/json hace esto internamente. Usa panic para saltar desde lo profundo del árbol de serialización cuando encuentra un error irrecuperable, y lo captura con recover en la función pública para devolver un error limpio.

// Ejemplo simplificado del patrón interno
type parseError struct {
    msg string
}

func (p *Parser) parse() (Nodo, error) {
    defer func() {
        if r := recover(); r != nil {
            if pe, ok := r.(parseError); ok {
                // Convertir panic interno en error público
                err = fmt.Errorf("error de parseo: %s", pe.msg)
            } else {
                panic(r) // Re-lanzar panics que no son nuestros
            }
        }
    }()

    return p.parseExpresion(), nil
}

func (p *Parser) parseExpresion() Nodo {
    // Recursión profunda...
    if p.tokenActual.Tipo == TokenInvalido {
        panic(parseError{msg: "token inesperado"}) // Salta al recover de parse()
    }
    // ...
}

Las reglas para este patrón:

  1. Nunca cruza la frontera de la API pública. El caller solo ve error.
  2. Usa un tipo de panic privado y específico para distinguirlo de panics reales.
  3. Si capturas algo que no es tu tipo de panic, re-lánzalo con panic(r).

Esto es aceptable porque es un detalle de implementación. Pero si estás tentado de usarlo, pregúntate primero si puedes pasar un error a través de las llamadas recursivas. En la mayoría de casos, puedes.


Reglas prácticas

Para cerrar, las reglas que aplico en mi código y que recomiendo:

Defer

  • Siempre difiere el cierre de recursos justo después de adquirirlos (y comprobar el error).
  • Nunca pongas defer dentro de un bucle si el bucle puede tener muchas iteraciones. Extrae una función.
  • Recuerda que los argumentos se evalúan en el momento del defer, no al ejecutarse. Si necesitas el valor final, usa un closure.
  • Usa defer con valores de retorno con nombre para capturar errores de cierre.

Panic

  • Solo para errores del programador: invariantes violadas, estados imposibles, inicialización fallida.
  • Funciones Must* que hacen panic son aceptables si solo se usan durante la inicialización del programa.
  • Nunca para errores operacionales: validación, I/O, red, base de datos, datos no encontrados.
  • Si dudas entre panic y error, la respuesta es error. Siempre.

Recover

  • Solo en fronteras: middleware HTTP, goroutines de trabajo, fronteras de librería pública.
  • Siempre registra el stack trace con debug.Stack(). Un panic recuperado sin log es un bug invisible.
  • Si capturas un panic que no es tuyo, re-lánzalo.
  • Nunca como mecanismo general de manejo de errores.

Go tiene un sistema de manejo de errores. Son los valores error. Defer, panic y recover son herramientas complementarias para escenarios específicos. Úsalas como lo que son.

Si quieres ver cómo se aplican estos conceptos en una API real, echa un vistazo a API REST con Go. Y si necesitas repasar los fundamentos del manejo de errores en Go, que es lo que vas a usar el 99% del tiempo, empieza por errores en Go.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados