En Go los errores son valores comunes que devuelves y revisas. Aprende a envolverlos con contexto, a encontrar la causa con errors.Is y errors.As, a juntar varios a la vez, y a guardar panic y recover para los bugs de verdad.
Go no tiene excepciones para los fallos comunes. Una función que puede fallar devuelve un error como su último resultado, y quien la llama decide qué hacer con él. Eso hace que el manejo de errores se vea en cada línea, y significa que las herramientas para agregar contexto y encontrar la causa importan mucho.
Este post cubre esas herramientas, desde errors.New hasta el wrapping, errors.Is, errors.As y errors.Join, y después panic y recover, que son para otro trabajo. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Un error es cualquier valor con un método Error
El tipo error de Go es una interfaz incorporada con un solo método, Error() string. Cualquier tipo que tenga ese método es un error. La biblioteca estándar te da dos formas rápidas de crear uno: errors.New para un mensaje fijo, y fmt.Errorf cuando el mensaje necesita valores.
package main
import (
"errors"
"fmt"
"strconv"
)
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("age %q is not a number", s)
}
if n < 0 {
return 0, errors.New("age can't be negative")
}
return n, nil
}
func main() {
for _, in := range []string{"42", "-3", "old"} {
age, err := parseAge(in)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("age:", age)
}
var err error = errors.New("disk full")
fmt.Printf("%T\n", err)
}
Imprime:
age: 42
error: age can't be negative
error: age "old" is not a number
*errors.errorString
parseAge devuelve un valor y un error. Si todo sale bien, el error es nil. Si falla, el valor es 0 y el error dice qué salió mal. Quien llama revisa if err != nil justo después de la llamada y lo resuelve antes de tocar age.
La última línea muestra lo que errors.New crea en realidad: un puntero a un pequeño struct no exportado que guarda el mensaje. Nunca necesitas ese tipo por su nombre. Solo necesitas el método Error, y fmt.Println lo llama por ti.
Por qué Go te obliga a revisar
La forma if err != nil { return err } aparece por todas partes en Go, y es a propósito. Una excepción puede saltar desde cualquier llamada, así que leyendo una función no puedes saber qué líneas podrían sacarte de ella antes de tiempo. En Go, cada línea que puede fallar tiene su revisión justo debajo. El camino del fallo es código común que puedes leer, recorrer paso a paso y probar.
El costo es escribir más. El beneficio es que nada sale de una función sin que veas por dónde. Y como mostró la parte sobre funciones, no puedes descartar un error por accidente: para ignorarlo tienes que escribir _, donde quien revisa el código lo puede ver.
Errores centinela y errors.Is
Un error centinela es un valor de error a nivel de paquete contra el que comparan quienes llaman. Por convención, su nombre empieza con Err. Lo creas una vez con errors.New y devuelves ese mismo valor cada vez que ocurre la condición.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var users = map[int]string{1: "ada", 2: "linus"}
func findUser(id int) (string, error) {
name, ok := users[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}
func main() {
_, err := findUser(7)
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
same := errors.New("not found")
fmt.Println(errors.Is(err, same))
}
Imprime:
not found
true
true
false
err == ErrNotFound es verdadero, porque findUser devolvió exactamente ese valor. errors.Is está de acuerdo. La última línea es la que hay que notar: un segundo error con el mismo texto no es el mismo error. errors.New crea un puntero nuevo cada vez, y los errores se comparan por identidad, no por mensaje. Así que nunca revises un error comparando su string.
La biblioteca estándar usa centinelas por todas partes. io.EOF significa «no hay más entrada», y sql.ErrNoRows significa que una consulta no encontró nada. Por ahora, == y errors.Is dan la misma respuesta. Dejan de coincidir en cuanto alguien agrega contexto, que es la siguiente sección.
Wrapping: agregar contexto con %w
Un «not found» pelado que sale de un programa grande no te dice casi nada. ¿No se encontró dónde? ¿Mientras hacía qué? La respuesta de Go es envolver el error: cada función que lo pasa hacia arriba agrega una nota corta sobre lo que estaba haciendo. fmt.Errorf envuelve cuando usas el verbo %w.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func openFile(name string) error {
return ErrNotFound
}
func readConfig(name string) error {
if err := openFile(name); err != nil {
return fmt.Errorf("open %s: %w", name, err)
}
return nil
}
func startServer() error {
if err := readConfig("config.json"); err != nil {
return fmt.Errorf("read config: %w", err)
}
return nil
}
func main() {
err := startServer()
if err != nil {
err = fmt.Errorf("start server: %w", err)
}
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" %q\n", e.Error())
}
}
Imprime:
start server: read config: open config.json: not found
false
true
"start server: read config: open config.json: not found"
"read config: open config.json: not found"
"open config.json: not found"
"not found"
El error subió tres niveles, y cada nivel puso sus propias palabras adelante. El mensaje final se lee de izquierda a derecha, desde la tarea más externa hasta la causa raíz. Ese es el estilo de Go: frases cortas en minúscula, unidas con dos puntos, sin «error:» ni «failed to» en cada nivel.
Ahora err == ErrNotFound es falso, porque err es un envoltorio (wrapper), no el centinela. errors.Is sigue siendo verdadero, porque mira adentro. El bucle del final muestra por dónde mira: errors.Unwrap quita una capa a la vez hasta llegar al original.
Explicado como si tuvieras diez años
Imagina una nota que se pasa de mano en mano por una fila de personas. El primer niño escribe «not found» y se la da a la siguiente persona.
Esa persona no tira la nota ni la reescribe. La mete en un sobre y escribe por fuera: «al abrir config.json:». La siguiente persona mete ese sobre en uno más grande y escribe «al leer la configuración:». Cuando llega a la maestra, el sobre de afuera cuenta toda la historia, y la nota original sigue en el medio.
errors.Is es la maestra abriendo sobre tras sobre, buscando la nota que dice «not found». No importa cuántos sobres haya.
La versión precisa
fmt.Errorf con %w devuelve un error que guarda tanto el mensaje nuevo como el error que le pasaste. Ese envoltorio tiene un método Unwrap() error que devuelve el error de adentro. La cadena es una lista enlazada: cada envoltorio apunta al que tiene adentro, y el error más interno no tiene Unwrap.
errors.Is(err, target) recorre esa lista. En cada paso revisa err == target, y también llama a un método Is(error) bool si el error tiene uno. Devuelve verdadero en la primera coincidencia y falso cuando se acaba la cadena. errors.Unwrap hace un paso del recorrido a mano.
Dónde falla la analogía: los sobres de verdad esconden la nota, pero un envoltorio de Go no. Su mensaje ya contiene el mensaje de adentro, y por eso imprimir el error externo muestra toda la cadena. Y envolver no es automático. Si una función usa %v en lugar de %w, copia el texto pero tira el sobre.
%w o %v
Los dos verbos imprimen el mismo mensaje, y solo uno de ellos conserva la cadena:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
wrapped := fmt.Errorf("load user 7: %w", ErrNotFound)
flattened := fmt.Errorf("load user 7: %v", ErrNotFound)
fmt.Println(wrapped)
fmt.Println(flattened)
fmt.Println(errors.Is(wrapped, ErrNotFound))
fmt.Println(errors.Is(flattened, ErrNotFound))
}
Imprime:
load user 7: not found
load user 7: not found
true
false
Leyendo la salida no puedes distinguirlos, y eso es lo que hace fácil pasar por alto la diferencia. Usa %w cuando quienes llaman puedan necesitar revisar la causa. Usa %v cuando quieras esconderla a propósito, por ejemplo para que un detalle de tu capa de almacenamiento no pase a formar parte de la API de tu paquete.
Maneja un error una sola vez
Cada error se debe manejar una vez: o lo devuelves, con contexto agregado, o lo registras en el log y ahí termina. Hacer las dos cosas es un hábito común, y llena los logs con el mismo fallo contado varias veces.
package main
import (
"errors"
"log"
"os"
)
func readConfig() error {
return errors.New("config.json: file not found")
}
func startServer() error {
err := readConfig()
if err != nil {
log.Println("could not read config:", err)
return err
}
return nil
}
func main() {
log.SetFlags(0)
log.SetOutput(os.Stdout)
if err := startServer(); err != nil {
log.Println("server failed:", err)
}
}
Imprime:
could not read config: config.json: file not found
server failed: config.json: file not found
(log.SetFlags(0) apaga la marca de tiempo para que la salida sea igual en cada ejecución.)
Un fallo, dos líneas de log. En un programa real con cinco capas, son cinco líneas, muchas veces lejos unas de otras en el log, y alguien que lo lee a las 3 de la mañana cuenta cinco problemas. Peor aún, la segunda línea perdió el contexto que tenía la primera.
La solución es elegir una. startServer debería hacer return fmt.Errorf("read config: %w", err) y no registrar nada. main está arriba de todo, así que no tiene a dónde devolver. Registra una vez, y esa única línea dice server failed: read config: config.json: file not found, con toda la historia en orden.
Tipos de error propios y errors.As
Un tipo de error propio lleva datos estructurados con los que quien llama puede actuar, no solo un mensaje. Cuando quien llama necesita saber qué campo falló la validación, o qué código de estado devolvió una llamada HTTP, un centinela no alcanza. Defines un struct con los campos y le das un método Error.
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
func validate(email string) error {
if email == "" {
return &ValidationError{Field: "email", Reason: "is required"}
}
return nil
}
func createUser(email string) error {
if err := validate(email); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func main() {
err := createUser("")
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("field:", ve.Field)
fmt.Println("reason:", ve.Reason)
}
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("AsType found field:", ve.Field)
}
}
Imprime:
create user: email: is required
field: email
reason: is required
AsType found field: email
errors.Is pregunta «¿este valor en particular está en algún lugar de la cadena?». errors.As pregunta «¿hay un error de este tipo en algún lugar de la cadena? Si lo hay, dámelo». Declaras una variable del tipo que quieres, pasas su dirección, y errors.As recorre la cadena. Cuando encuentra un *ValidationError, lo guarda en ve y devuelve verdadero. El envoltorio de createUser no estorbó.
El puntero a ve confunde a la gente. errors.As necesita un lugar donde poner lo que encuentra, así que recibe &ve, que es un **ValidationError. Si pasas ve directamente, go vet te detiene.
Go 1.26 agrega errors.AsType, una versión genérica que devuelve la coincidencia y un bool, sin tener que declarar una variable antes. Hace el mismo recorrido. Vas a ver errors.As en casi todo el código existente, y AsType en el código nuevo a medida que se adopte.
Una advertencia cuando escribas funciones como validate. Devuelve un nil simple cuando todo sale bien, nunca un *ValidationError nil guardado en un error. Ese segundo no es igual a nil: es la trampa de la interfaz nil de la parte sobre interfaces.
errors.Join: varios errores a la vez
A veces una llamada tiene varias cosas independientes mal, y reportar solo la primera obliga al usuario a corregirlas de a una. errors.Join, agregado en Go 1.20, combina errores en uno solo.
package main
import (
"errors"
"fmt"
)
var ErrTooShort = errors.New("password too short")
func checkSignup(name, password string) error {
var errs []error
if name == "" {
errs = append(errs, errors.New("name is required"))
}
if len(password) < 8 {
errs = append(errs, ErrTooShort)
}
return errors.Join(errs...)
}
func main() {
err := checkSignup("", "abc")
fmt.Println(err)
fmt.Println("---")
fmt.Println(errors.Is(err, ErrTooShort))
fmt.Println(checkSignup("ada", "correct horse"))
}
Imprime:
name is required
password too short
---
true
<nil>
El mensaje del error combinado pone cada error en su propia línea. errors.Is y errors.As buscan en cada rama, así que la revisión de ErrTooShort sigue funcionando.
La última línea es la parte práctica. errors.Join ignora los errores nil, y cuando no queda nada devuelve nil. Así que puedes juntar problemas en un slice y devolver errors.Join(errs...) sin revisar si el slice está vacío.
panic es para bugs, no para errores
Un panic detiene el flujo normal de una goroutine. Las llamadas diferidas se siguen ejecutando, y después, salvo que algo lo recupere, todo el programa se cae con un mensaje y un stack trace. Ese es el resultado correcto para un bug: algo que el programador hizo mal, o un estado que el código cree imposible. Un archivo que falta, una entrada inválida del usuario o un timeout de red no son bugs. Son errores, y se devuelven.
package main
import "fmt"
type Direction int
const (
North Direction = iota
South
)
func (d Direction) String() string {
switch d {
case North:
return "north"
case South:
return "south"
}
panic(fmt.Sprintf("unknown Direction %d", int(d)))
}
func main() {
fmt.Println(North.String())
fmt.Println(Direction(9).String())
}
Imprime dos líneas y se detiene:
north
panic: unknown Direction 9
Después de esas dos líneas viene goroutine 1 [running]: y un stack trace que apunta a la línea del panic, y el programa termina con código de salida 2. Solo hay dos direcciones, así que Direction(9) significa que algún código en algún lugar construyó un valor que no debía. Devolver un error aquí obligaría a todos los que llaman a String a manejar un caso que solo un bug puede causar. Un panic dice «arregla el código» en voz alta, justo donde salió mal.
El runtime hace panic por el mismo tipo de razón: indexar más allá del final de un slice, escribir en un map nil, dividir un entero por cero. Ya conociste el primero de esos en la parte sobre slices.
recover: convertir un panic de nuevo en un error
recover es una función incorporada que detiene un panic en curso y devuelve el valor que se le pasó a panic. Solo tiene efecto cuando se llama directamente desde una función diferida mientras la goroutine está en panic. El uso normal es en una frontera: código que ejecuta la función de otra persona y no quiere que el bug de esa función tumbe todo.
package main
import (
"errors"
"fmt"
)
func safeRun(name string, job func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("job %s panicked: %v", name, r)
}
}()
job()
return nil
}
func main() {
err := safeRun("ok", func() {
fmt.Println("running ok")
})
fmt.Println("result:", err)
err = safeRun("bad", func() {
var counts map[string]int
counts["x"]++
})
fmt.Println("result:", err)
err = safeRun("custom", func() {
panic(errors.New("state machine reached an impossible state"))
})
fmt.Println("result:", err)
fmt.Println("main carries on")
}
Imprime:
running ok
result: <nil>
result: job bad panicked: assignment to entry in nil map
result: job custom panicked: state machine reached an impossible state
main carries on
El primer job se ejecutó normalmente. recover devolvió nil, así que la función diferida no cambió nada. El segundo job escribió en un map nil, lo que hace que el runtime entre en panic. El panic salió de job(), la función diferida se ejecutó, recover lo atrapó, y el closure puso un error en el resultado con nombre err. Es el mismo truco que el closure diferido de la parte sobre funciones. El tercer job hizo panic a propósito y se atrapó de la misma forma.
Esto es exactamente lo que hace el servidor net/http de Go con cada petición. Si un handler hace panic, su petición se descarta y se registra en el log, y el servidor sigue atendiendo a todos los demás. Lo vas a ver en la parte sobre servidores HTTP.
No uses recover para armar excepciones a partir de panic. Si una función puede fallar de forma normal, devuelve un error. Recupera solo en una frontera donde una caída sería peor que un fallo registrado.
Dónde recover no funciona
Una llamada a recover solo detiene un panic cuando una función diferida la hace directamente. Llamada en cualquier otro lugar, no hace nada y devuelve nil.
package main
import "fmt"
func helper() {
if r := recover(); r != nil {
fmt.Println("helper recovered:", r)
}
}
func main() {
fmt.Println("recover outside a panic:", recover())
defer func() {
helper()
fmt.Println("the deferred function returns")
}()
panic("boom")
}
Imprime tres líneas y se detiene:
recover outside a panic: <nil>
the deferred function returns
panic: boom
El primer recover se ejecutó cuando nada estaba en panic, así que devolvió nil. El segundo está dentro de helper, y a helper la llama la función diferida en lugar de ser ella la función diferida. Ese único nivel de indirección basta: recover devuelve nil, helper no imprime nada, y el panic sigue y tumba el programa. Mover la llamada a recover al propio closure diferido lo atraparía.
El otro límite son las goroutines. Un recover diferido solo atrapa panics de su propia goroutine:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main recovered:", r)
}
}()
go func() {
panic("worker failed")
}()
select {} // wait forever; the worker's panic ends the program first
}
Imprime una línea y se detiene:
panic: worker failed
main tiene un recover perfectamente válido, y nunca se ejecuta. El panic ocurrió en otra goroutine, que no tiene su propio recover diferido, y un panic sin recuperar en cualquier goroutine tumba todo el programa. Por eso el código que lanza goroutines con trabajo no confiable pone el defer–recover dentro de cada goroutine. La parte sobre goroutines explica cómo lanzarlas y esperarlas como se debe.
Volver a hacer panic
A veces una función diferida recupera un panic, lo mira y decide que al final no lo puede manejar. Puede registrar lo que sabe y llamar otra vez a panic con el mismo valor.
package main
import "fmt"
func main() {
defer func() {
r := recover()
fmt.Println("logging, then panicking again:", r)
panic(r)
}()
panic("invariant broken: balance below zero")
}
Imprime dos líneas y se detiene:
logging, then panicking again: invariant broken: balance below zero
panic: invariant broken: balance below zero [recovered, repanicked]
Mira el final de la segunda línea. Cuando se recupera un panic y se vuelve a hacer panic con el mismo valor, el mensaje de la caída dice [recovered, repanicked] e imprime el valor una sola vez. Si ves esa marca en una caída, algún código más arriba en la pila atrapó el panic y lo dejó seguir.
¿Cuál debería usar?
La elección depende de quién necesita reaccionar, y cómo.
| Situación | Usa | Quien llama revisa con |
|---|---|---|
| Algo falló, y quien llama solo necesita saber que falló | Devuelve un error de errors.New o fmt.Errorf, envolviendo con %w |
err != nil |
| Quien llama necesita reconocer una condición específica, como «not found» | Un centinela: var ErrNotFound = errors.New(...) |
errors.Is |
| Quien llama necesita detalles: qué campo, qué código, cuánto esperar para reintentar | Un tipo de error propio con campos | errors.As o errors.AsType |
| Varias cosas independientes fallaron a la vez | errors.Join |
errors.Is o errors.As, que buscan en cada parte |
| Un bug o un estado imposible, algo que solo un cambio de código arregla | panic |
Nada. Arregla el código. Recupera solo en una frontera |
Qué recordar
errores una interfaz con un método,Error() string. Devuélvelo como último resultado y revisaif err != niljusto después de la llamada.- Maneja cada error una vez: devuélvelo con contexto, o regístralo en el log. No las dos cosas.
- Envuelve con
fmt.Errorf("doing X: %w", err). El mensaje se arma nivel por nivel, y%vperdería la cadena. errors.Isencuentra un valor específico en cualquier lugar de la cadena. Compara errores con él, no con==y nunca por el texto del mensaje.errors.As, oerrors.AsTypedesde Go 1.26, saca un tipo de error propio de la cadena para que puedas leer sus campos.errors.Joincombina varios errores y devuelvenilcuando todos sonnil.panices para bugs.recoversolo funciona cuando se llama directamente en una función diferida de la goroutine en panic, y su lugar es en las fronteras.
Cada vez que un error sube un nivel, agrega lo que estabas haciendo, y guarda el original adentro.