The error string hides the truth
You call a gRPC service. The call fails. The error prints as rpc error: code = Internal desc = database connection lost. You see "Internal" and panic. Or you see "NotFound" and retry. The error value is not just a message. It is a packet containing a status code, a message, and sometimes debug details. Treating it as a plain string throws away the signal. You need to decode the code to decide whether to retry, alert, or return a 404 to your user.
gRPC errors carry structured codes
gRPC defines a set of canonical status codes. These codes are part of the protocol, not just Go conventions. The codes include OK, Canceled, Unknown, InvalidArgument, DeadlineExceeded, NotFound, AlreadyExists, PermissionDenied, ResourceExhausted, FailedPrecondition, Aborted, OutOfRange, Unimplemented, Internal, Unavailable, DataLoss, and Unauthenticated.
The Go client library converts the wire format into an error interface. The error interface only has Error() string. The code lives inside the concrete type. The status.FromError function is the bridge. It checks if the error implements the internal interface that holds the status. If it does, you get the code and message. If not, you get a boolean telling you the error is something else, like a network dial failure.
Codes are the signal. Messages are for humans. Branch on codes.
Extracting the status
Here's the pattern to extract the code. Spawn a fake error, check it, branch on the code.
package main
import (
"log"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// simulateGRPCError returns a fake gRPC error for demonstration.
// This mimics what a server sends back to the client.
func simulateGRPCError() error {
// status.Error creates a gRPC error with a specific code and message.
// The code drives client logic; the message is for logging.
return status.Error(codes.NotFound, "user 42 does not exist")
}
func main() {
err := simulateGRPCError()
if err != nil {
// FromError extracts the gRPC status from the error chain.
// It returns the status and a boolean indicating if the error was gRPC-related.
st, ok := status.FromError(err)
if ok {
// ok is true means this is a gRPC error with a defined code.
// You can now inspect st.Code() and st.Message().
log.Printf("gRPC error: code=%v, message=%s", st.Code(), st.Message())
} else {
// ok is false means the error is not a gRPC status error.
// This could be a network timeout, context cancellation, or a raw error.
log.Printf("Non-gRPC error: %v", err)
}
}
}
How the error chain works
When status.FromError runs, it walks the error chain. If you wrapped the error with fmt.Errorf("wrapper: %w", err), FromError still finds the gRPC status. This is crucial. It means you can wrap errors in your domain layer and still inspect the gRPC code later. The function returns a *status.Status and a boolean. The boolean tells you if the error was a gRPC status error. If ok is false, the error is something else, like a network dial failure or a context error that wasn't wrapped by gRPC.
Under the hood, status.FromError uses errors.As. It is essentially checking if the error chain contains a value that satisfies the gRPC status interface. Knowing this helps you understand why it works with wrapping. If you wrap an error without %w, the chain breaks. FromError returns ok = false. You lose the code.
The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot ignore an error in Go without explicitly discarding it. This discipline prevents silent failures in distributed systems.
Wrap errors with %w. Lose the chain, lose the code.
Handling errors in production code
Real code lives in a service layer. You call gRPC, handle the code, and return a domain error. The client should map gRPC codes to application logic. NotFound might become a 404 response. Unavailable might trigger a retry. Internal might trigger an alert.
Here's a realistic handler that fetches a user and maps gRPC codes to domain errors.
package service
import (
"context"
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ErrUserNotFound indicates the user does not exist.
var ErrUserNotFound = errors.New("user not found")
// GetUser fetches a user by ID from the gRPC service.
// It returns the user or an error, handling gRPC codes explicitly.
func GetUser(ctx context.Context, client UserServiceClient, id int64) (*User, error) {
// Call the gRPC method. The context propagates deadlines and cancellation.
// context.Context is always the first parameter by convention.
resp, err := client.GetUser(ctx, &GetUserRequest{Id: id})
if err != nil {
// FromError unwraps the error to check for gRPC status codes.
// It works even if the error was wrapped by fmt.Errorf earlier.
st, ok := status.FromError(err)
if !ok {
// The error is not a gRPC status error.
// This usually indicates a transport issue like a timeout or connection drop.
return nil, fmt.Errorf("transport error: %w", err)
}
// Check specific codes to decide how to handle the failure.
switch st.Code() {
case codes.NotFound:
// The resource doesn't exist. Return a specific error for the caller.
return nil, ErrUserNotFound
case codes.InvalidArgument:
// The client sent bad data. This is a client-side bug.
return nil, fmt.Errorf("invalid request: %w", err)
case codes.Unavailable:
// The service is down. The caller might want to retry.
return nil, fmt.Errorf("service unavailable: %w", err)
default:
// Unknown or server error. Wrap and return.
return nil, fmt.Errorf("gRPC error: %w", err)
}
}
return resp, nil
}
The receiver name in a method would be one or two letters, like (s *Service) GetUser. Here we use a function, so no receiver. The ctx parameter is first. The error handling branches on codes, not strings. This keeps the logic stable even if the server changes its error messages.
Check ok before calling Code(). Nil pointer panics don't log.
Common traps and compiler checks
String matching is fragile. Code like strings.Contains(err.Error(), "NotFound") breaks when the server updates its message or adds localization. Codes are stable integers. Always use st.Code().
Ignoring the ok return value is dangerous. If ok is false, st is nil. Calling st.Code() panics. The compiler won't catch this. You must check ok.
Creating errors on the client side with fmt.Errorf instead of status.Error loses the gRPC code. If you are writing a server handler, use status.Error to return codes. On the client, you receive them. If you need to create a gRPC error for testing, use status.Error.
Context deadlines inside gRPC become codes.DeadlineExceeded. Context cancellation becomes codes.Canceled. FromError sees these as gRPC errors. A raw context.DeadlineExceeded error passed manually is not a gRPC error. FromError returns ok = false for raw context errors.
If you pass the wrong type to FromError, the compiler rejects it. Passing a string results in cannot use "message" (untyped string constant) as error value in argument. Forgetting to import the package gives undefined: status. These errors are plain text. Read them. They tell you exactly what type is missing.
Codes are the signal. Messages are for humans. Branch on codes.
Choosing the right tool
Use status.FromError when you need to inspect the gRPC status code from a client-side error. Use status.Error when you are writing a server handler and need to return a specific gRPC code to the client. Use fmt.Errorf with %w when you are wrapping an error in your application logic but want to preserve the original error chain. Use codes constants when you are comparing status codes in a switch or if statement. Use raw error checks when you only care that something failed and don't need to distinguish between gRPC codes and transport errors.