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
MustXxxcon 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. Retornaerror. - 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
deferen 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 querecover()retornenil. En su lugar,recover()retorna*runtime.PanicNilError. El código antiguo que usarecover() == nilpara verificar “sin panic” ahora es más preciso.
7. Conclusión
Estas tres herramientas tienen roles muy diferentes:
deferes una herramienta de uso diario para limpiar recursos. Recuerda dos cosas: los argumentos se evalúan inmediatamente cuando encuentrasdefer, ydeferse vincula con la función no con el bucle. Combinado con named result,deferte ayuda a no perder errores deClose().panices para errores de programación y errores de inicialización irrecuperables. Para otros errores, retornaerror.recoversolo 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.