gRPC Error Codes in Go and Rich Error Details for Clients
Choose the right gRPC status code in Go, attach BadRequest, RetryInfo and ErrorInfo details, map domain errors in one place, and stop internal text leaking.

When a gRPC call fails, the caller gets a status code and a message. The code is the part software acts on: whether to retry, whether to show the user a form error or a "try again later" banner, whether to page someone. The message is for humans. Most gRPC services get the codes roughly right, and most leak a little too much in the message and have no structured detail at all.
Tested with Go 1.25.6, grpc-go v1.75.0, genproto errdetails, protoc 29.3, macOS on Apple Silicon (arm64)
This article covers the three parts of a good error design: choosing the code, attaching typed details so clients do not parse messages, and keeping the mapping from your domain's errors in one place. It builds on unary RPCs and the earlier Go error handling series. For the HTTP equivalent, see problem details. It ran on Go 1.25.6.
The 17 codes
The program printed every codes.Code from 0 to 16. Here they are with what each should mean in a service, grouped by what the caller does next.
The caller should fix the request:
| Code | Use for |
|---|---|
InvalidArgument (3) | The request is wrong whatever the state of the system: a bad field, a malformed id |
OutOfRange (11) | A value is well-formed but outside the valid range, such as a page past the end |
NotFound (5) | The named thing does not exist (or the caller may not know it does) |
AlreadyExists (6) | A create collided with an existing resource |
The caller should change the state or the rights:
| Code | Use for |
|---|---|
FailedPrecondition (9) | The system is not in the state this call needs: deleting a non-empty folder, insufficient funds |
Unauthenticated (16) | No valid credentials |
PermissionDenied (7) | Valid credentials, not allowed |
ResourceExhausted (8) | A quota or rate limit was hit |
The caller may retry:
| Code | Use for |
|---|---|
Unavailable (14) | The service is temporarily unable: safe to retry with backoff |
Aborted (10) | A concurrency conflict such as a failed optimistic update: retry the whole operation |
DeadlineExceeded (4) | The time limit passed. The operation may or may not have happened |
Nobody can fix it from here:
| Code | Use for |
|---|---|
Internal (13) | An invariant broke: a bug. Generic message |
Unknown (2) | An error with no code, or from another system that gave none |
Unimplemented (12) | The method is not supported or not implemented |
DataLoss (15) | Unrecoverable data loss or corruption |
Canceled (1) | The caller cancelled |
OK (0) | Success |
The pairs people confuse
InvalidArgument versus FailedPrecondition. If the same request would succeed in some other state of the system, it is a precondition. "Withdraw 100 from an account with 50" is valid input and fails because of the balance, so FailedPrecondition. "Withdraw -100" is wrong in any state, so InvalidArgument.
NotFound versus PermissionDenied. For objects the caller may not be allowed to know about, NotFound hides their existence (BOLA).
Unauthenticated versus PermissionDenied. The first means "who are you?", the second "you may not".
Unavailable versus Internal. Unavailable tells clients and retry policies that trying again may work; Internal says it will not. Return Unavailable for a database that is briefly unreachable, Internal for a nil dereference.
Aborted versus FailedPrecondition. Aborted is for a conflict the client should retry from the beginning (optimistic concurrency, a transaction deadlock). FailedPrecondition is for a state that will not change just because the client tries again.
These categories are what the retry configuration in retries and hedging uses: you name Unavailable as retryable, and that only works if services use it honestly.
Rich error details
A code and a message is thin. A form with five fields wants to know which field. A client told to back off wants to know how long. gRPC solves this with details: protobuf messages attached to the status and sent in the trailers. The standard ones are in google.golang.org/genproto/googleapis/rpc/errdetails:
BadRequestwith a list ofFieldViolations (field path and description).RetryInfowith aRetryDelay.ErrorInfowith a machine-readableReason, aDomainand a metadata map: the best choice for errors your client code will switch on.QuotaFailure,PreconditionFailure,ResourceInfo,Help,LocalizedMessage,DebugInfofor the corresponding situations.
On the server, status.New(code, msg).WithDetails(msgs...) returns a new status; .Err() turns it into an error. On the client, st.Details() returns the list, to be examined with a type switch. The lab service exercises three.
syntax = "proto3";
package bank.v1;
option go_package = "example.test/lab/bankpb";
service Bank {
rpc Transfer(TransferRequest) returns (TransferReply);
}
message TransferRequest { string from = 1; string to = 2; int64 cents = 3; }
message TransferReply { string id = 1; }//go:build tools
package main
import (
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
pb "example.test/lab/bankpb"
)
// Domain errors know nothing about gRPC. One function maps them to statuses at the edge.
var (
ErrInsufficientFunds = errors.New("insufficient funds")
ErrAccountFrozen = errors.New("account is frozen")
)
type bank struct{ pb.UnimplementedBankServer }
func (bank) Transfer(ctx context.Context, r *pb.TransferRequest) (*pb.TransferReply, error) {
switch {
case r.GetFrom() == "" || r.GetCents() <= 0:
// Field-level problems: a BadRequest detail lets a form highlight the right inputs.
st := status.New(codes.InvalidArgument, "the request is invalid")
var violations []*errdetails.BadRequest_FieldViolation
if r.GetFrom() == "" {
violations = append(violations, &errdetails.BadRequest_FieldViolation{Field: "from", Description: "is required"})
}
if r.GetCents() <= 0 {
violations = append(violations, &errdetails.BadRequest_FieldViolation{Field: "cents", Description: "must be greater than zero"})
}
st, _ = st.WithDetails(&errdetails.BadRequest{FieldViolations: violations})
return nil, st.Err()
case r.GetFrom() == "slow-down":
st, _ := status.New(codes.ResourceExhausted, "too many transfers").
WithDetails(&errdetails.RetryInfo{RetryDelay: durationpb.New(2 * time.Second)})
return nil, st.Err()
case r.GetFrom() == "frozen":
return nil, toStatus(fmt.Errorf("transfer from %s: %w", r.GetFrom(), ErrAccountFrozen))
case r.GetFrom() == "poor":
return nil, toStatus(fmt.Errorf("transfer from %s: %w", r.GetFrom(), ErrInsufficientFunds))
case r.GetFrom() == "buggy":
return nil, errors.New("nil map assignment in ledger.go:88") // an unclassified error
}
return &pb.TransferReply{Id: "tx_1"}, nil
}
func toStatus(err error) error {
switch {
case errors.Is(err, ErrInsufficientFunds):
st, _ := status.New(codes.FailedPrecondition, err.Error()).WithDetails(&errdetails.ErrorInfo{
Reason: "INSUFFICIENT_FUNDS", Domain: "bank.example.test", Metadata: map[string]string{"currency": "EUR"}})
return st.Err()
case errors.Is(err, ErrAccountFrozen):
return status.Error(codes.PermissionDenied, "account is frozen")
}
return status.Error(codes.Internal, "internal error")
}
func main() {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
srv := grpc.NewServer()
pb.RegisterBankServer(srv, bank{})
go srv.Serve(lis)
defer srv.Stop()
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
c := pb.NewBankClient(conn)
for _, req := range []*pb.TransferRequest{
{From: "", Cents: -5}, {From: "slow-down", Cents: 1}, {From: "poor", Cents: 1}, {From: "frozen", Cents: 1}, {From: "buggy", Cents: 1}, {From: "ada", Cents: 100},
} {
_, err := c.Transfer(context.Background(), req)
st := status.Convert(err)
fmt.Printf("from=%-10q -> %-18s %q\n", req.GetFrom(), st.Code(), st.Message())
for _, d := range st.Details() {
switch v := d.(type) {
case *errdetails.BadRequest:
for _, f := range v.GetFieldViolations() {
fmt.Printf(" field %s: %s\n", f.GetField(), f.GetDescription())
}
case *errdetails.RetryInfo:
fmt.Printf(" retry after %v\n", v.GetRetryDelay().AsDuration())
case *errdetails.ErrorInfo:
fmt.Printf(" reason=%s domain=%s metadata=%v\n", v.GetReason(), v.GetDomain(), v.GetMetadata())
}
}
}
fmt.Println()
for code := codes.Code(0); code <= codes.Unauthenticated; code++ {
fmt.Printf("%2d %s\n", code, code)
}
}mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go run .from="" -> InvalidArgument "the request is invalid"
field from: is required
field cents: must be greater than zero
from="slow-down" -> ResourceExhausted "too many transfers"
retry after 2s
from="poor" -> FailedPrecondition "transfer from poor: insufficient funds"
reason=INSUFFICIENT_FUNDS domain=bank.example.test metadata=map[currency:EUR]
from="frozen" -> PermissionDenied "account is frozen"
from="buggy" -> Unknown "nil map assignment in ledger.go:88"
from="ada" -> OK ""
0 OK
1 Canceled
2 Unknown
3 InvalidArgument
4 DeadlineExceeded
5 NotFound
6 AlreadyExists
7 PermissionDenied
8 ResourceExhausted
9 FailedPrecondition
10 Aborted
11 OutOfRange
12 Unimplemented
13 Internal
14 Unavailable
15 DataLoss
16 UnauthenticatedReading the results
Field violations. The request with an empty from and cents: -5 returned InvalidArgument with a BadRequest listing two violations. A client with a form maps each Field to an input and shows Description beside it. The top-level message, "the request is invalid", is generic; the details carry the specifics. One response lists all the problems, so the user fixes them in one go and does not discover them one by one.
Retry information. A rate-limited call returned ResourceExhausted with RetryInfo{RetryDelay: 2s}. A client can honour it exactly, as it would a Retry-After header. It is a hint; gRPC's built-in retry does not use it, so your code or interceptor does.
A reason to switch on. The insufficient-funds case returned FailedPrecondition and an ErrorInfo with Reason: "INSUFFICIENT_FUNDS", a domain name and metadata (currency: EUR). The reason is a stable identifier. The message ("transfer from poor: insufficient funds") can change wording; the reason cannot. Client code branches on Reason, not on message text. Choose reasons in upper snake case, document them as part of the API and treat them as permanent, like field numbers.
Mapping domain errors in one place
The toStatus function in the lab is the pattern: domain code returns plain Go errors such as ErrInsufficientFunds and ErrAccountFrozen, possibly wrapped with context using %w, and a single function at the edge turns them into statuses with errors.Is. Three benefits. The domain layer stays free of gRPC imports and can be reused behind REST. The mapping is reviewable in one screen. And the wrapping text added deep in the code (transfer from frozen: ...) does not reach the client for cases where you substitute a clean message, as the frozen account case shows: the client message was account is frozen, not the full chain.
Do this in an interceptor if every method follows the same mapping (interceptors): the interceptor calls the handler, and if the error is not already a status, maps it.
The leak
The buggy case is a warning. The handler returned a plain errors.New("nil map assignment in ledger.go:88") and the client received code Unknown with that exact text, source file and line included. Any error the server does not convert is sent to the client verbatim as the message of an Unknown status. The fix is the last line of toStatus: anything unrecognised becomes Internal with a generic message, and the original error is logged on the server with the request id. Put that as a fallback in the mapping interceptor and the leak cannot happen, whatever a handler returns.
Two more leaks to watch. Database driver errors often contain SQL text and table names. And errors from outgoing gRPC calls, if returned unchanged, pass the downstream service's code and message to your caller, which can turn a downstream NotFound into a misleading NotFound for the wrong resource. Translate at each boundary.
HTTP equivalents
If the service is also exposed over HTTP through a gateway or Connect, codes map to HTTP statuses in a standard table: InvalidArgument to 400, Unauthenticated to 401, PermissionDenied to 403, NotFound to 404, AlreadyExists and Aborted to 409, FailedPrecondition to 400 in the gateway (some tables say 412), ResourceExhausted to 429, Internal to 500, Unimplemented to 501, Unavailable to 503 and DeadlineExceeded to 504. The detail messages travel as JSON in the response body.
Testing errors
Table-test the mapping function with every domain error and an unknown one. Test that handlers return the right code for each failure path, extracting with status.Code(err). Test that details decode: st.Details() on the client should contain the expected type. And test the fallback: a handler that returns a raw error must produce Internal with a generic message. The status and codes packages are documented in the grpc status package, the code semantics in the gRPC status codes guide, and the detail types in Google's error model.


