defer, panic y recover en Go: cuándo usarlos

1. Descripción general

Go no tiene try/catch. En su lugar, el lenguaje proporciona tres herramientas: defer para limpiar recursos, panic para reportar errores irrecuperables, y recover para interceptar panic en un límite específico.

Este artículo explica el mecanismo de cada herramienta, las trampas comunes, y lo más importante: cuándo usarlas, cuándo no. Después de leerlo, sabrás cómo usar defer correctamente, en qué momentos panic es razonable, y cómo usar recover para evitar que tu servidor se caiga por un request erróneo.

2. defer

defer pospone una llamada de función hasta que la función que la contiene termine. La función puede terminar con un return normal o debido a un panic. En ambos casos, las llamadas marcadas con defer se ejecutan.

2.1. Orden de ejecución LIFO

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

Cada vez que encuentras defer, Go empuja la llamada a un stack propio de la función. Cuando main termina, las llamadas se sacan en orden inverso:

done
defer 3
defer 2
defer 1

Este orden LIFO tiene una razón. Los recursos abiertos después generalmente dependen de los abiertos antes. Por ejemplo, abres una conexión y luego una transacción, así que la transacción debe cerrarse antes que la conexión.

2.2. Los argumentos se evalúan inmediatamente

Esta es la primera trampa. Los argumentos de la función con defer se calculan en el momento en que encuentras defer, no cuando la llamada realmente se ejecuta.

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

En la primera llamada, el valor 10 se captura inmediatamente cuando encuentras defer. El closure en la segunda llamada es diferente. Hace referencia a la variable total, así que cuando se ejecuta leerá el valor más reciente que es 20.

Cuando necesitas el valor final, por ejemplo para medir tiempo o registrar un estado, usa un closure.

2.3. Modificar el valor retornado con named result

Un closure con defer puede leer y escribir el named result de la función. Esta técnica es muy útil cuando Close() puede retornar un error, por ejemplo al escribir un archivo donde los datos realmente se vacían solo cuando cierras.

package main

import (
	"errors"
	"fmt"
	"os"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

La declaración return err asigna el valor a la variable err, luego se ejecuta el closure con defer. El closure combina el error de f.Close() con err usando errors.Join, disponible desde Go 1.20. errors.Join retorna nil si ambos errores son nil.

Si solo escribes defer f.Close(), el error al cerrar el archivo se tragará sin que nadie lo sepa.

Nota: Para archivos de solo lectura, defer f.Close() es suficiente. El error al cerrar un archivo read-only casi no afecta los datos.

2.4. Trampa: defer en bucles

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // INCORRECTO: se cierra solo cuando processAll termina
		// ... leer f ...
	}
	return nil
}

defer se vincula con la función, no con un bloque o bucle. Con 10.000 archivos, el código anterior mantiene 10.000 file descriptors abiertos al mismo tiempo y muy fácilmente encontrarás el error too many open files.

La forma de arreglarlo es extraer el cuerpo del bucle en una función separada:

func processAll(paths []string) error {
	for _, p := range paths {
		if err := processFile(p); err != nil {
			return err
		}
	}
	return nil
}

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // se cierra cuando processFile retorna
	// ... leer f ...
	return nil
}

3. panic

panic detiene la ejecución normal de la goroutine actual. Go comienza a desplegar (unwind) el stack: cada función retorna hacia arriba, y los defer de cada función se ejecutan. Cuando llegamos al tope del stack de la goroutine y no hay nadie que haga recover, el programa imprime el stack trace y sale con código 2.

3.1. Observar el proceso de despliegue

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

Ves que ambos defer se ejecutan antes de que el programa muera. Esta es exactamente la razón por la que defer mu.Unlock() o defer tx.Rollback() siguen siendo seguros cuando hay panic.

Panic también puede venir del runtime y no solo de llamadas panic() explícitas. Los casos comunes son acceder a un índice fuera de rango, desreferenciar un puntero nil, escribir en un map nil, o dividir un número entero entre 0.

3.2. Cuándo usar panic

La regla general de Go: si un error es predecible, retorna error. Por ejemplo, si un archivo no existe, entrada con formato incorrecto o timeout de red. Solo usa panic en algunos casos.

1. Error de programación, es decir, violación de invariante. Este es un estado “imposible” si el código es correcto, y continuar ejecutando solo corrompe los datos.

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. Funciones MustXxx usadas durante la inicialización. La librería estándar tiene regexp.MustCompile y template.Must. Puedes escribir funciones similares para configuración:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

Si la configuración es incorrecta, la aplicación no debería arrancar. Hacer panic inmediatamente al inicio te ayuda a detectar errores temprano, en lugar de dejar que el servidor corra en un estado corrupto.

Nota: No uses MustXxx con datos del usuario o requests. Una entrada mala no debería permitir que caiga todo el proceso.

4. recover

recover() detiene el proceso de despliegue y retorna el valor pasado a panic. Esta función solo funciona cuando se llama directamente dentro de una función con defer. Llamarla en cualquier otro lugar retorna nil y no hace nada.

4.1. Convertir panic en error

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

Cuando b == 0, el runtime hace panic. El closure con defer atrapa el panic con recover() y lo asigna a la variable err. La función retorna normalmente con result manteniendo el valor cero que es 0.

Este ejemplo es solo para ilustrar. En la práctica, deberías verificar b == 0 y retornar un error directamente.

4.2. Trampa: recover debe llamarse directamente

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK: logPanic es la función con defer
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // NO funciona: recover está más profundo en la pila
	}()
	panic("boom B")
}

handlerA recupera con éxito. handlerB todavía hace que el programa se caiga, porque recover se llama dentro de logPanic, pero logPanic se llama por la función con defer, no es ella misma una función con defer.

4.3. Trampa: recover no atrapa panic de otra goroutine

Cada goroutine tiene su propio stack. Un panic en una goroutine hija mata todo el proceso, sin importar si main tiene recover o no.

func main() {
	defer func() { recover() }() // inútil para la goroutine de abajo

	go func() {
		panic("worker crashed") // el proceso sigue muerto
	}()
	time.Sleep(time.Second)
}

Si ejecutas un job en segundo plano y no puedes controlar todo el código adentro, envuélvelo:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() está en el paquete runtime/debug. Te ayuda a loguear el stack trace suficiente para que puedas debuggear después.

4.4. Recover en el límite HTTP

Un lugar razonable para poner recover es el límite del request. Un request erróneo no debería arrastrar a otros requests.

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // dejar que net/http maneje el abort como está diseñado
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http ya hace recover del panic para cada conexión, pero solo lo registra y cierra la conexión, así que el cliente no recibe una respuesta amable. El middleware arriba retorna un código 500 claro, y al mismo tiempo registra el método y la ruta para fácil seguimiento.

5. Escribir tests para panic y recover

Go usa el paquete testing estándar, sin necesidad de dependencias externas.

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

El segundo test usa defer y recover para afirmar que la función hace panic, y hace panic con el mensaje correcto. Si usas testify, assert.PanicsWithValue da un resultado similar con menos código.

6. Cuándo NO usar

  • No uses panic/recover como excepciones. Escribir panic(ErrNotFound) y luego recover en la capa superior hace que el flujo de control sea difícil de leer, y va contra las convenciones de Go. Retorna error.
  • No hagas panic en librerías públicas cuando el error viene de la entrada del llamador. Los usuarios de tu librería no esperan que su proceso muera.
  • No hagas recover e ignores silenciosamente. Al menos registra el valor del panic y el stack trace. Si el estado está corrompido, por ejemplo los datos compartidos se escribieron de forma incompleta, deja que el proceso muera y que el orquestador lo reinicie.
  • No uses defer en bucles largos (ver sección 2.4).
  • No te preocupes demasiado por el rendimiento de defer. Desde Go 1.14, los open-coded defer tienen casi cero costo en la mayoría de casos. Solo optimiza cuando el profiler indica que es un punto caliente.

Nota: Desde Go 1.21, panic(nil) ya no hace que recover() retorne nil. En su lugar, recover() retorna *runtime.PanicNilError. El código antiguo que usa recover() == nil para verificar “sin panic” ahora es más preciso.

7. Conclusión

Estas tres herramientas tienen roles muy diferentes:

  • defer es una herramienta de uso diario para limpiar recursos. Recuerda dos cosas: los argumentos se evalúan inmediatamente cuando encuentras defer, y defer se vincula con la función no con el bucle. Combinado con named result, defer te ayuda a no perder errores de Close().
  • panic es para errores de programación y errores de inicialización irrecuperables. Para otros errores, retorna error.
  • recover solo debe aparecer en los límites: middleware HTTP, goroutine de trabajo, o donde llamas código que no controlas. Debe llamarse directamente en una función con defer, y solo atrapa panic de esa misma goroutine.

Dominando estos límites, escribirás código Go que sea seguro y verdadero al espíritu del lenguaje: los errores son valores, y panic es una excepción real.

golangdeferpanicrecovererror-handling