Error handling en Go en 2026: errors.Is, errors.As y wrapping sin perderte

En Go los errores no se lanzan ni se capturan: se devuelven. Cada función que puede fallar devuelve un valor de tipo error junto al resultado, y quien la llama decide qué hacer con él. Eso es todo. Sin excepciones, sin try/catch, sin stack traces automáticos volando por ahí.

Esta sencillez tiene un precio: escribes más código. Pero el flujo queda explícito y el control nunca se escapa a ningún sitio inesperado. Una vez que te acostumbras, la verbosidad deja de molestar y empiezas a apreciar que el compilador te fuerce a pensar en cada fallo posible.

El modelo de errores de Go

error es una interfaz con un solo método:

type error interface {
    Error() string
}

Cualquier tipo que implemente ese método es un error. Las funciones que pueden fallar siguen el patrón (T, error):

func openFile(path string) (*os.File, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    return f, nil
}

El caller comprueba si err != nil y actúa en consecuencia. No hay magia detrás. La diferencia con las excepciones es que el flujo de control es completamente lineal: ves exactamente dónde puede fallar cada cosa sin necesidad de seguir la pista a un throw que viaja por varios niveles de la pila.

fmt.Errorf con %w: envolver errores con contexto

Cuando capturas un error en una capa y lo propagas hacia arriba, lo normal es añadir contexto para saber dónde ocurrió. Para eso existe fmt.Errorf con el verbo %w:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("al leer el fichero de config: %w", err)
    }
    // ...
}

El %w envuelve el error original dentro del nuevo. Así el mensaje acumula contexto en cada capa: "al conectar a la BD: al parsear el DSN: falta el host". Cuando llegas arriba y lo imprimes, tienes toda la cadena de lo que falló y dónde.

Ojo: %v también formatea el error como texto, pero no lo envuelve. Si usas %v en lugar de %w, pierdes la referencia al error original y luego no puedes usar errors.Is ni errors.As para inspeccionarlo. Usa siempre %w cuando quieras preservar la cadena.

errors.Is: comparar con un error concreto

Antes de Go 1.13 la comparación era directa: err == ErrNotFound. El problema es que eso falla en cuanto el error está envuelto dentro de otro. Con errors.Is no importa cuántas capas haya:

var ErrNotFound = errors.New("not found")

func findUser(id int) (*User, error) {
    u, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("al buscar usuario %d: %w", id, err)
    }
    return u, nil
}

// En el caller:
u, err := findUser(42)
if errors.Is(err, ErrNotFound) {
    // el usuario no existe, lo tratamos como caso normal
}

errors.Is recorre la cadena de wrapped errors buscando uno que coincida con el target. Si tu error está tres niveles adentro, lo encuentra igual. Define errores centinela con errors.New para las condiciones de flujo que esperas: ErrNotFound, ErrUnauthorized, ErrTimeout... así el caller puede tomar decisiones sin parsear strings.

errors.As: extraer un tipo de error concreto

Cuando el error centinela no te basta y necesitas datos concretos del fallo, defines un tipo propio:

type ValidationError struct {
    Field string
    Msg   string
}

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

Y luego lo extraes con errors.As:

err := validateForm(data)
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("error en el campo '%s': %sn", valErr.Field, valErr.Msg)
}

errors.As recorre la cadena igual que errors.Is, pero en lugar de comparar por valor busca el primer error que sea del tipo indicado. Es muy útil cuando trabajas con errores de librerías externas que contienen información estructurada: códigos HTTP, paths de fichero, identificadores de recurso.

errors.Join: reportar varios fallos a la vez

Llegó en Go 1.20 y resuelve un caso bastante común: procesas una lista de items y no quieres parar al primer fallo, sino reportar todos los que fallen:

func validateAll(items []Item) error {
    var errs []error
    for _, item := range items {
        if err := validate(item); err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

El error resultante combina todos los mensajes y errors.Is sigue funcionando: si preguntas por un error concreto, lo busca en todos los errores combinados. Antes de Join la gente construía sus propios tipos de multi-error o devolvía solo el primero. Ahora hay una forma estándar.

errors.Unwrap: bajar un nivel en la cadena

errors.Unwrap devuelve el error interno que hay dentro de uno envuelto. En la práctica pocas veces lo llamas directamente porque errors.Is y errors.As ya lo usan internamente. Pero si implementas un tipo de error propio que envuelve a otro, añades el método:

func (e *MyError) Unwrap() error {
    return e.cause
}

Para errores con múltiples wrapped (como los que produce errors.Join), el método devuelve un slice:

func (e *MultiError) Unwrap() []error {
    return e.errs
}

Así errors.Is y errors.As saben que tienen que recorrer varios ramos, no solo uno lineal.

Qué patrón usar en cada situación

Hay tres herramientas y cada una tiene su sitio:

Errores centinela

Para condiciones de flujo esperadas que el caller necesita distinguir. Un ErrNotFound no es un fallo grave: el caller simplemente toma otra ruta. Los defines en el paquete y el caller usa errors.Is. Son la forma más ligera y la más fácil de documentar.

Tipos de error propios

Cuando el caller necesita datos del fallo, no solo saber que ocurrió. Un *ValidationError con el campo y el mensaje, un *HTTPError con el código de estado, un *PathError con la ruta. El caller usa errors.As para extraer el tipo y leer los campos.

Wrapping con contexto

Para errores de infraestructura que suben por varias capas. Cada capa añade fmt.Errorf("al hacer X: %w", err) y arriba del todo tienes el contexto completo. Un return err sin envolver también es válido cuando no tienes nada útil que añadir: no hace falta envolver por envolver.

Panic vs error: cuándo usar cada uno

panic en Go está reservado para errores de programación: índice fuera de rango, nil pointer dereference, aserción de tipo fallida. Cosas que no deberían ocurrir si el código está bien escrito. Para todo lo demás, lo que puede fallar en producción de forma legítima, devuelves error.

// Mal: no hagas panic por errores de runtime esperados
func loadUser(id int) *User {
    u, err := db.Find(id)
    if err != nil {
        panic(err) // nunca hagas esto
    }
    return u
}

// Bien
func loadUser(id int) (*User, error) {
    u, err := db.Find(id)
    if err != nil {
        return nil, fmt.Errorf("al cargar usuario %d: %w", id, err)
    }
    return u, nil
}

Donde sí tiene sentido usar recover es en servidores HTTP: un handler que hace panic no debería tirar el proceso entero. El middleware de recuperación lo atrapa y devuelve un 500:

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic recuperado: %v", rec)
                http.Error(w, "error interno", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Fuera de ese caso concreto, si te encuentras usando panic para manejar errores de negocio, es una señal de que algo en el diseño no está bien.

Un ejemplo completo

Para verlo todo junto: un servicio que carga un usuario por ID, con errores tipados, wrapping en cada capa y comprobación en el handler:

var ErrUserNotFound = errors.New("usuario no encontrado")

type DBError struct {
    Query string
    Cause error
}

func (e *DBError) Error() string {
    return fmt.Sprintf("error en query '%s': %v", e.Query, e.Cause)
}

func (e *DBError) Unwrap() error {
    return e.Cause
}

func getUserFromDB(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, &DBError{Query: "SELECT users", Cause: err}
    }
    return &u, nil
}

func getUser(id int) (*User, error) {
    u, err := getUserFromDB(id)
    if err != nil {
        return nil, fmt.Errorf("getUser(%d): %w", id, err)
    }
    return u, nil
}

// En el handler HTTP:
u, err := getUser(42)
if errors.Is(err, ErrUserNotFound) {
    http.Error(w, "usuario no encontrado", http.StatusNotFound)
    return
}
var dbErr *DBError
if errors.As(err, &dbErr) {
    log.Printf("fallo de BD: %v", dbErr)
    http.Error(w, "error interno", http.StatusInternalServerError)
    return
}

El handler sabe exactamente qué pasó y responde de forma diferente según el tipo de fallo. El contexto queda en los logs. Y todo el flujo es lineal y fácil de seguir.

Para seguir

Imagen: Pexels / Brett Sayles

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP