Errores en Go: por qué no hay excepciones y cómo escribir código claro
Manejo de errores en Go: por qué son explícitos, cómo usar wrapping, errores centinela y personalizados. Sin excepciones, con claridad.

La primera vez que vi if err != nil repetido veinte veces en un fichero, pensé que el manejo de errores en Go era una locura. Venía de Java y Kotlin, donde un try-catch te resolvía la vida, y de Python, donde las excepciones vuelan por el aire y ya las pillarás en algún middleware. Ver que Go te obligaba a comprobar cada error, en cada línea, me parecía un retroceso de veinte años.
Ahora pienso que es una de las mejores decisiones de diseño del lenguaje.
No porque sea elegante. No lo es. Pero porque te fuerza a hacer visible el flujo real del programa. Cada punto donde algo puede fallar está ahí, delante de ti, sin esconderse detrás de una pila de llamadas que nadie inspeccionó. Y cuando depuras un problema en producción a las tres de la mañana, esa visibilidad vale más que toda la elegancia del mundo.
Este artículo va de eso: de cómo funciona el manejo de errores en Go, por qué es así y cómo escribir código que sea claro sin volverte loco con la verbosidad.
Por qué Go no tiene excepciones
La ausencia de excepciones en Go no es un descuido. Es una decisión deliberada y documentada. Los creadores del lenguaje (Rob Pike, Ken Thompson, Robert Griesemer) consideraron que las excepciones crean flujos de control ocultos que hacen el código más difícil de razonar.
En Java o Python, cuando una función lanza una excepción, esa excepción puede propagarse a través de diez niveles de la pila de llamadas hasta que alguien la capture. O nadie la capture y el programa reviente. El problema es que entre el punto de lanzamiento y el punto de captura, hay código que no sabe que algo ha fallado. Recursos que no se liberan, estados que quedan inconsistentes, transacciones que quedan a medias.
Go toma una posición radical: si algo puede fallar, la función que lo llama tiene que saberlo inmediatamente. No hay propagación implícita. No hay throws en la firma. No hay bloques try-catch que envuelvan quince líneas de código heterogéneo. Hay un valor de retorno que dice “esto ha fallado” y tú decides qué hacer con él.
file, err := os.Open("config.yaml")
if err != nil {
// Aquí decides: ¿retornas el error? ¿Usas un valor por defecto? ¿Lo loggeas?
return fmt.Errorf("no se pudo abrir la configuración: %w", err)
}
defer file.Close()Esto es verboso. Absolutamente. Pero tiene una propiedad que las excepciones no tienen: el flujo de error es local. No necesitas rastrear la pila de llamadas para saber qué pasa cuando os.Open falla. Está ahí, en las tres líneas siguientes.
Si vienes de un lenguaje con excepciones, esto te va a incomodar al principio. Es normal. Pero te pido que le des una oportunidad real antes de descartarlo. En Effective Go explicado hablo más sobre la filosofía general del lenguaje, y el manejo de errores es quizá donde más se nota.
La interfaz error: simplicidad por diseño
En Go, un error no es una clase especial, ni un tipo mágico del runtime. Es una interfaz con un solo método:
type error interface {
Error() string
}Cualquier tipo que implemente el método Error() string es un error. Eso es todo. No hay jerarquías de excepciones, no hay Throwable, no hay BaseException. Un error es cualquier cosa que pueda describirse a sí misma como texto.
Esto tiene consecuencias importantes. La primera es que puedes crear errores triviales con errors.New o fmt.Errorf:
import "errors"
var ErrNotFound = errors.New("recurso no encontrado")
func findUser(id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("ID inválido: %d", id)
}
// ...
}La segunda es que puedes crear tipos de error complejos cuando lo necesites, con campos adicionales, contexto estructurado y lo que haga falta. Pero no estás obligado a hacerlo. La interfaz es tan mínima que la barrera de entrada para crear y manejar errores es prácticamente cero.
Esto encaja con la filosofía de Go: las abstracciones simples que componen bien son más valiosas que las abstracciones complejas que cubren todos los casos.
El patrón básico: if err != nil
Este es el patrón que vas a escribir cien veces al día en Go. Y no es exageración:
result, err := doSomething()
if err != nil {
return err
}
// Usar result con la certeza de que no hay errorLa función devuelve dos valores: el resultado y un error. Si el error no es nil, algo ha fallado. Si es nil, puedes usar el resultado con confianza.
Lo que parece repetitivo es en realidad una garantía: cada punto de fallo tiene un manejo explícito. No hay errores silenciosos. No hay excepciones que se propagan sin control. No hay “ya lo pillaré más arriba”.
Veamos un ejemplo más realista. Una función que lee un fichero de configuración, lo parsea como JSON y devuelve una estructura:
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("leyendo configuración: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parseando configuración: %w", err)
}
if cfg.Port == 0 {
return nil, errors.New("el puerto no puede ser cero")
}
return &cfg, nil
}Tres operaciones que pueden fallar, tres comprobaciones explícitas. Cada una con un mensaje que te dice exactamente qué estaba pasando cuando falló. Cuando veas leyendo configuración: open config.yaml: no such file or directory en los logs, sabrás exactamente dónde buscar.
Un detalle importante: fíjate en que reutilizo la variable err dentro del if. En Go es idiomático declarar err en el scope del if cuando solo la necesitas para la comprobación:
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parseando configuración: %w", err)
}Esto mantiene el scope de err limitado al bloque if, lo que es más limpio cuando tienes múltiples comprobaciones seguidas.
Wrapping de errores con fmt.Errorf y %w
Uno de los avances más importantes en el manejo de errores de Go llegó en Go 1.13 con el wrapping de errores. Antes, si querías añadir contexto a un error, perdías la información del error original:
// Antes de Go 1.13: se pierde el error original
return fmt.Errorf("fallo al conectar: %v", err)Con %v creas un error nuevo cuyo mensaje incluye el texto del error original, pero la cadena de errores se rompe. No puedes inspeccionar qué tipo de error era el original.
El verbo %w soluciona esto:
// Con wrapping: conservas el error original
return fmt.Errorf("fallo al conectar a la base de datos: %w", err)Ahora el error resultante contiene el error original envuelto. Puedes inspeccionarlo con errors.Is o errors.As, recorrer la cadena de errores y tomar decisiones basadas en el error raíz.
La regla es sencilla: usa %w cuando quieras que quien llame a tu función pueda inspeccionar el error original. Usa %v cuando quieras encapsular el error y ocultar los detalles de implementación.
Un ejemplo práctico. Imagina un servicio que accede a una base de datos:
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("obteniendo usuario %d: %w", id, err)
}
return user, nil
}Aquí usamos %w porque queremos que la capa superior pueda distinguir si el error es un “no encontrado” o un fallo de conexión. Pero si estuvieras en una capa de API pública y no quisieras exponer detalles internos de la base de datos, usarías %v para crear un error opaco.
El wrapping puede encadenarse. Si el repositorio también envolvió el error con %w, acabas con una cadena como:
obteniendo usuario 42: consultando base de datos: dial tcp 127.0.0.1:5432: connection refusedCada capa añade su contexto, y el error raíz sigue siendo accesible mediante las funciones del paquete errors.
errors.Is y errors.As: inspeccionando tipos de error
Con el wrapping viene la necesidad de inspeccionar errores envueltos. Para eso existen errors.Is y errors.As.
errors.Is: comprobar identidad
errors.Is recorre la cadena de errores envueltos y comprueba si alguno coincide con un error específico:
import (
"errors"
"os"
)
_, err := os.Open("config.yaml")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("El fichero no existe, usando configuración por defecto")
} else if err != nil {
return fmt.Errorf("error inesperado abriendo config: %w", err)
}Lo crucial aquí es que errors.Is funciona a través de capas de wrapping. Si alguien envolvió os.ErrNotExist con tres capas de fmt.Errorf("...: %w", err), errors.Is sigue encontrándolo. Es por esto que siempre debes usar errors.Is en lugar de comparar directamente con ==:
// MAL: no funciona con errores envueltos
if err == os.ErrNotExist {
// BIEN: funciona a través de wrapping
if errors.Is(err, os.ErrNotExist) {errors.As: comprobar tipo
errors.As es el equivalente para tipos de error personalizados. Recorre la cadena de errores y comprueba si alguno es del tipo que buscas:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("Error en la ruta: %s, operación: %s\n", pathErr.Path, pathErr.Op)
}errors.As no solo comprueba el tipo, sino que asigna el valor al puntero que le pasas. Es como un type assertion pero que funciona a través de capas de wrapping.
Un patrón habitual en APIs HTTP es definir un tipo de error propio y usar errors.As en el handler para decidir el código de respuesta:
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
// En el handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
result, err := service.DoSomething(r.Context())
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
http.Error(w, appErr.Message, appErr.Code)
return
}
http.Error(w, "Error interno", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}Errores centinela: valores de error a nivel de paquete
Los errores centinela (sentinel errors) son variables de error declaradas a nivel de paquete que representan condiciones de error específicas y conocidas. Los has visto en la librería estándar:
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)La convención en Go es clara: los errores centinela empiezan con Err (excepto io.EOF, que es una excepción histórica). Son valores, no tipos. Los comparas con errors.Is, no con errors.As.
Son útiles cuando tu paquete necesita exponer condiciones de error que los consumidores van a querer comprobar:
package user
var (
ErrNotFound = errors.New("usuario no encontrado")
ErrAlreadyExists = errors.New("el usuario ya existe")
ErrInvalidEmail = errors.New("email inválido")
)
func (s *Service) Create(ctx context.Context, u *User) error {
existing, err := s.repo.FindByEmail(ctx, u.Email)
if err != nil && !errors.Is(err, ErrNotFound) {
return fmt.Errorf("comprobando email existente: %w", err)
}
if existing != nil {
return ErrAlreadyExists
}
// ...
}Y en la capa que consume este servicio:
err := userService.Create(ctx, newUser)
if errors.Is(err, user.ErrAlreadyExists) {
http.Error(w, "El email ya está registrado", http.StatusConflict)
return
}
if err != nil {
http.Error(w, "Error interno", http.StatusInternalServerError)
return
}Cuándo usar errores centinela
Usa errores centinela cuando:
- La condición de error es predecible y conocida (no encontrado, ya existe, no autorizado).
- Los consumidores de tu paquete necesitan tomar decisiones basadas en el tipo de error.
- El error no necesita contexto adicional más allá de su identidad.
No uses errores centinela para:
- Errores que solo vas a loggear sin tomar decisiones.
- Errores con contexto dinámico (como un ID de usuario o un nombre de fichero).
- Errores internos que no deberían escapar del paquete.
Un error centinela es parte de la API pública de tu paquete. Trátalo como tal. Cambiarlo o eliminarlo rompe a los consumidores.
Tipos de error personalizados: implementando la interfaz error
Cuando necesitas más información que un simple string, puedes crear tu propio tipo de error. Solo necesitas implementar el método Error() string:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validación fallida en campo '%s': %s", e.Field, e.Message)
}
func ValidateUser(u *User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "no puede estar vacío"}
}
if !strings.Contains(u.Email, "@") {
return &ValidationError{Field: "email", Message: "formato inválido"}
}
return nil
}El consumidor puede inspeccionar los campos del error:
err := ValidateUser(user)
if err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Campo '%s' inválido: %s\n", valErr.Field, valErr.Message)
}
}Errores con múltiples campos
Para APIs, un patrón que funciona bien es un tipo de error que incluya código HTTP, mensaje para el usuario y el error interno:
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Internal error `json:"-"`
}
func (e *APIError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Internal)
}
return e.Message
}
func (e *APIError) Unwrap() error {
return e.Internal
}
// Constructores para errores comunes
func NewNotFoundError(resource string, id any) *APIError {
return &APIError{
StatusCode: http.StatusNotFound,
Message: fmt.Sprintf("%s no encontrado", resource),
Detail: fmt.Sprintf("ID: %v", id),
}
}
func NewInternalError(err error) *APIError {
return &APIError{
StatusCode: http.StatusInternalServerError,
Message: "Error interno del servidor",
Internal: err,
}
}Fíjate en el método Unwrap() error. Esto permite que errors.Is y errors.As recorran la cadena de errores a través de tu tipo personalizado. Si tu tipo de error envuelve otro error, siempre implementa Unwrap.
Errores comunes: lo que no deberías hacer
Después de trabajar con Go un tiempo, empiezas a reconocer patrones de manejo de errores que parecen razonables pero acaban causando problemas. Estos son los más frecuentes.
Ignorar errores
El peor error. Y el compilador de Go te avisa si ignoras un valor de retorno, pero hay formas de silenciarlo:
// MAL: ignorar el error explícitamente
result, _ := doSomething()
// MAL: no comprobar el error de Close
file.Close()
// BIEN: manejar el error, incluso si es solo loggearlo
result, err := doSomething()
if err != nil {
log.Printf("fallo en doSomething: %v", err)
// decidir qué hacer
}
// BIEN: comprobar el error de Close en defer
defer func() {
if err := file.Close(); err != nil {
log.Printf("error cerrando fichero: %v", err)
}
}()El _ para descartar errores solo es aceptable cuando estás absolutamente seguro de que el error no puede ocurrir o no te afecta. En la práctica, casi nunca es el caso.
Over-wrapping: añadir contexto redundante
Añadir contexto a los errores es bueno. Añadir demasiado contexto crea mensajes ilegibles:
// MAL: cada capa repite información
return fmt.Errorf("error en GetUser: fallo al obtener usuario: %w", err)
// Resultado: "error en GetUser: fallo al obtener usuario: consultando DB: dial tcp..."
// BIEN: añade solo el contexto nuevo
return fmt.Errorf("obteniendo usuario %d: %w", id, err)
// Resultado: "obteniendo usuario 42: consultando DB: dial tcp..."El nombre de la función ya está en el stack trace si lo necesitas. Lo que aporta valor es el contexto que la función conoce: IDs, nombres de ficheros, operaciones específicas. No repitas el nombre de la función ni uses frases genéricas como “error en” o “fallo al”.
Usar panic como sistema de excepciones
panic existe en Go, pero no es un sistema de excepciones. Es para situaciones irrecuperables donde el programa no puede continuar:
// MAL: usar panic para errores de negocio
func GetUser(id int) *User {
user, err := db.Find(id)
if err != nil {
panic(err) // NO hagas esto
}
return user
}
// BIEN: devolver el error
func GetUser(id int) (*User, error) {
user, err := db.Find(id)
if err != nil {
return nil, fmt.Errorf("buscando usuario %d: %w", id, err)
}
return user, nil
}Los únicos usos legítimos de panic son:
- Errores de programación que indican un bug (índice fuera de rango, nil pointer en un lugar imposible).
- Inicialización del programa que falla (no puedes conectar a la base de datos al arrancar).
- Tests, donde
panicequivale a unt.Fatal.
Si estás usando panic y recover como throw y catch, estás luchando contra el lenguaje. Para entender más sobre defer, panic y recover, te recomiendo consultar la documentación oficial de Go.
No comprobar errores en goroutines
Este es sutil pero peligroso. Si lanzas una goroutine y el código dentro falla, el error se pierde silenciosamente:
// MAL: error perdido
go func() {
result, err := doExpensiveWork()
if err != nil {
log.Printf("error: %v", err) // ¿Quién ve este log?
return
}
processResult(result)
}()
// BIEN: comunicar el error por un canal
errCh := make(chan error, 1)
go func() {
result, err := doExpensiveWork()
if err != nil {
errCh <- fmt.Errorf("trabajo costoso: %w", err)
return
}
processResult(result)
errCh <- nil
}()
if err := <-errCh; err != nil {
// Manejar el error
}Si trabajas con múltiples goroutines, errgroup del paquete golang.org/x/sync/errgroup es la herramienta correcta:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return fetchUsers(ctx)
})
g.Go(func() error {
return fetchOrders(ctx)
})
if err := g.Wait(); err != nil {
return fmt.Errorf("fallo en operaciones paralelas: %w", err)
}Patrones prácticos para APIs: errores en handlers y servicios
Si estás construyendo una API REST con Go, el manejo de errores es donde más vas a notar la diferencia con otros lenguajes. En lugar de un middleware global que captura excepciones, necesitas una estrategia deliberada.
Patrón 1: handler con switch de errores
El enfoque más directo. El handler llama al servicio y decide el código HTTP basándose en el error:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "ID inválido", http.StatusBadRequest)
return
}
user, err := h.service.GetUser(r.Context(), id)
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "Usuario no encontrado", http.StatusNotFound)
case errors.Is(err, ErrUnauthorized):
http.Error(w, "No autorizado", http.StatusUnauthorized)
default:
log.Printf("error obteniendo usuario %d: %v", id, err)
http.Error(w, "Error interno", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}Simple y explícito. Pero si tienes veinte handlers, repites el mismo switch en cada uno.
Patrón 2: handler wrapper con tipo de error
Un enfoque más escalable es definir un tipo de handler que devuelve error y un middleware que lo traduce:
type AppHandler func(w http.ResponseWriter, r *http.Request) error
func HandleErrors(h AppHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
var apiErr *APIError
if errors.As(err, &apiErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.StatusCode)
json.NewEncoder(w).Encode(apiErr)
return
}
log.Printf("error no manejado: %v", err)
http.Error(w, "Error interno", http.StatusInternalServerError)
}
}
// Uso
mux.HandleFunc("GET /users/{id}", HandleErrors(func(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return &APIError{StatusCode: 400, Message: "ID inválido"}
}
user, err := h.service.GetUser(r.Context(), id)
if err != nil {
return err // El wrapper decide el código HTTP
}
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(user)
}))Este patrón centraliza la lógica de traducción de errores a HTTP sin perder la explicitud del manejo de errores en cada handler.
Patrón 3: errores en capas de servicio
En la capa de servicio, la regla es: añade contexto y propaga. No traduzcas errores a códigos HTTP aquí. Eso es responsabilidad del handler.
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("consultando usuario %d: %w", id, err)
}
if !user.Active {
return nil, ErrUnauthorized
}
return user, nil
}Fíjate en algo importante: la capa de servicio transforma sql.ErrNoRows (un detalle de implementación de la base de datos) en ErrNotFound (un concepto de dominio). Esto desacopla tu lógica de negocio de la tecnología de persistencia. Si mañana cambias PostgreSQL por MongoDB, los handlers no necesitan cambiar.
Cuándo NO complicar el manejo de errores
Después de todo lo anterior, es tentador montar una arquitectura de errores elaborada con tipos personalizados, wrapping en cada capa y un sistema de códigos de error digno de una RFC. No lo hagas.
La mayoría de aplicaciones en Go necesitan muy poco:
- Errores centinela para dos o tres condiciones conocidas (
ErrNotFound,ErrAlreadyExists). - Wrapping con
%wpara mantener la cadena de contexto. - Un tipo de error personalizado si estás construyendo una API y necesitas mapear errores a códigos HTTP.
Y ya. Nada más.
No crees una jerarquía de errores como en Java con IOException, FileNotFoundException, SocketException. Go no está diseñado para eso. Un error es un valor que describe qué falló. Cuanto más simple sea tu sistema de errores, más fácil será trabajar con él.
Para scripts, CLIs y herramientas internas, muchas veces fmt.Errorf con %w es todo lo que necesitas. No hace falta crear errores centinela si nadie va a inspeccionarlos. No hace falta crear tipos personalizados si el string del error es suficiente contexto.
La regla que sigo: empieza con fmt.Errorf y %w. Añade errores centinela cuando un consumidor necesite tomar decisiones. Añade tipos personalizados cuando necesites campos estructurados. Y para cuando escribes tests en Go, la simplicidad de los errores como valores hace que probar condiciones de error sea mucho más directo que mockear excepciones.
Comparación con excepciones (Java/Python/Kotlin)
Vengo de Kotlin y Java. Trabajo con Python a diario. Conozco los tres sistemas de excepciones y puedo decir con conocimiento de causa que el enfoque de Go no es ni mejor ni peor. Es diferente, y tiene trade-offs claros.
Java: excepciones checked y unchecked
Java tiene el sistema de excepciones más formal de los tres. Las checked exceptions te obligan a manejarlas o declararlas en la firma del método:
public User getUser(int id) throws UserNotFoundException, DatabaseException {
// ...
}En teoría, esto da garantías similares a Go: sabes qué puede fallar. En la práctica, la mayoría de equipos acaba envolviendo todo en RuntimeException para no tener que declarar excepciones en cada método de la cadena. Las checked exceptions fueron una buena idea que el ecosistema rechazó.
Python: excepciones como flujo de control
Python usa excepciones para todo. No solo para errores:
try:
value = my_dict["key"]
except KeyError:
value = "default"Esto es idiomático en Python (“easier to ask forgiveness than permission”). Pero significa que cualquier función puede lanzar cualquier excepción en cualquier momento, y la única forma de saberlo es leer la documentación (que puede no existir) o el código fuente.
Kotlin: excepciones unchecked con Result
Kotlin eliminó las checked exceptions y confía en que el programador maneje los errores. Tiene un tipo Result<T> que se acerca al enfoque de Go:
fun getUser(id: Int): Result<User> {
return runCatching { repository.findById(id) }
}
// Uso
getUser(42)
.onSuccess { user -> println(user) }
.onFailure { error -> println("Error: $error") }Pero Result es opcional. La mayoría del código Kotlin sigue usando excepciones.
El trade-off real
| Aspecto | Go | Java (checked) | Python / Kotlin |
|---|---|---|---|
| Visibilidad del error | Siempre visible | Visible en la firma | Invisible sin documentación |
| Verbosidad | Alta | Media-alta | Baja |
| Propagación | Explícita | Semi-explícita | Implícita |
| Riesgo de error silenciado | Bajo (_ es visible) | Medio (catch vacío) | Alto (bare except) |
| Composición | Valores normales | Mecanismo especial | Mecanismo especial |
| Stack trace automático | No (necesitas wrapping) | Sí | Sí |
La ventaja principal de Go es que los errores son valores. No son un mecanismo especial del lenguaje con reglas propias. Son valores de retorno normales que puedes almacenar en variables, pasar a funciones, meter en slices, testear con igualdad. Esto hace que el manejo de errores sea código normal, no un sistema paralelo con su propia sintaxis.
La desventaja principal es la verbosidad y la falta de stack traces automáticos. En Java o Python, cuando una excepción revienta, tienes toda la pila de llamadas. En Go, si no envuelves tus errores con contexto en cada capa, acabas con un mensaje críptico que no te dice dónde se originó el problema.
Por eso el wrapping con %w no es opcional en la práctica. Es obligatorio si quieres errores útiles.
El coste de la claridad
El manejo de errores en Go es verboso. Eso es innegable. Vienes de Kotlin con sus sealed classes o de Python con su try/except, y el primer mes con Go sientes que escribes más código del necesario solo para manejar casos de fallo.
Pero hay algo que cambia con el tiempo. Empiezas a leer código Go de otros y entiendes exactamente qué ocurre cuando algo falla. No hay flujos ocultos, no hay excepciones que saltan tres capas arriba sin que nadie las espere, no hay catch vacíos escondidos en un middleware que alguien escribió hace dos años. Todo está ahí, delante de ti, en cada if err != nil.
Esa visibilidad tiene un coste al escribir. Pero tiene un valor enorme al mantener. Y mantener es lo que hacemos el 80% del tiempo.
Si vienes de otro lenguaje, mi consejo es este: no intentes replicar excepciones con panic/recover. No montes jerarquías de errores como si estuvieras en Java. Empieza con fmt.Errorf y %w, añade errores centinela cuando un consumidor necesite tomar decisiones, y crea tipos personalizados solo cuando necesites campos estructurados. Nada más.
El manejo de errores en Go parece repetitivo hasta que entiendes que te fuerza a hacer visible el flujo real del programa. Cada if err != nil es una decisión consciente sobre qué hacer cuando algo falla. Y eso, en producción, es exactamente lo que quieres.


