This document will walk you through all you need to know to migrate from v1 to v2. connect-go v2 improves and simplifies some common APIs.
To get started we will first install the migration tool. This will help automate most of the mechanical translations. Then go through examples, and any decisions that might show up and require oversight.
Important
connect-go v1 remains supported. The v1 branch will receive fixes and security updates, so you can migrate at your own pace.
We provide the tool connect-go-v2-migrate which takes care of plugin updates and
most code changes. First, install the tool:
go install connectrpc.com/connect/v2/cmd/connect-go-v2-migrate@latestThen run it from your module directory, or pass paths as arguments:
connect-go-v2-migrateThe tool is safe to run. By default it is a dry run that prints a diff for each
file it would change, plus warnings for anything that needs manual work. Pass
-w to write the changes to disk, and -json for a machine-readable report.
Most code changes depend on the re-generated v2 code, so the migration runs in two passes:
- Run
connect-go-v2-migrate -wto updatebuf.gen.yamland apply any code changes that don't depend on generated code. - Re-generate your code with the v2 plugin (see Re-generate code).
- Run
connect-go-v2-migrate -wagain to finish the changes that bind to the v2 generated code. This will update client calls and handler signatures. - Finally, run
go mod tidyand then build and test addressing any manual operations warned from the report.
The project won't compile between steps 1 and 3. Work through the sequence, then fix any remaining warnings the tool printed. Services may be migrated one at a time to reduce the changes, both v1 and v2 libraries can be imported in the same module.
The sections below show each change. Changes are marked:
- ✅
connect-go-v2-migratehandles this. ⚠️ The tool prints a warning. Update the code by hand.
The v2 generator keeps the plugin name protoc-gen-connect-go. The required
change depends on how buf.gen.yaml declares the plugin.
For remote plugins, the reference is pinned to the v2 release:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- - remote: buf.build/connectrpc/go:v1.18.1
+ - remote: buf.build/connectrpc/go:v2.0.0
out: gen
opt: paths=source_relative✅ connect-go-v2-migrate handles this. It also replaces
buf.build/connectrpc/gosimple, since v2 makes the simple API the default
generator.
BSR generated SDKs move to a /v2 module, so their import paths change:
-import "buf.build/gen/go/acme/user/connectrpc/go/acme/user/v1/userv1connect"
+import "buf.build/gen/go/acme/user/connectrpc/go/v2/acme/user/v1/userv1connect"✅ connect-go-v2-migrate handles this in the first pass and adds the /v2
module to the go get command it prints.
For local plugins (local: protoc-gen-connect-go), the buf.gen.yaml entry
stays the same because the v1 and v2 plugins share the binary name.
Reinstalling the binary from the v2 module switches generation to v2:
go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latestIf the plugin runs through go.mod (local: [go, tool, protoc-gen-connect-go]),
update the tool dependency instead:
go get -tool connectrpc.com/connect/v2/cmd/protoc-gen-connect-go
go mod tidyv2 generated code is always in the simple style, and the generator rejects the
v1 simple option:
version: v2
plugins:
- local: protoc-gen-connect-go
out: gen
opt:
- paths=source_relative
- - simple=true✅ connect-go-v2-migrate handles this, for both the list form and the inline
form (opt: paths=source_relative,simple=true).
With buf.gen.yaml updated and the plugin installed, re-generate:
buf generateThe migration tool never edits generated code, so this step is yours. After
re-generating, run connect-go-v2-migrate -w again to migrate the code that
depends on the generated packages.
The tool rewrites import paths in your code but does not edit go.mod. After the final tool run, resolve the new modules:
go mod tidyThe core module and grpchealth and grpcreflect move to /v2 module paths.
validate and otelconnect keep their paths:
| v1 | v2 |
|---|---|
connectrpc.com/connect |
connectrpc.com/connect/v2 |
connectrpc.com/grpchealth |
connectrpc.com/grpchealth/v2 |
connectrpc.com/grpcreflect |
connectrpc.com/grpcreflect/v2 |
connectrpc.com/validate |
connectrpc.com/validate |
connectrpc.com/otelconnect |
connectrpc.com/otelconnect |
Until the ecosystem packages are tagged, use the main branch:
go get connectrpc.com/grpchealth/v2@main connectrpc.com/grpcreflect/v2@main \
connectrpc.com/validate@main connectrpc.com/otelconnect@mainThe tool prints this command for the packages you import.
connectrpc.com/authn keeps working on v1, so it can stay as is. The API
changes in each package are
covered in Ecosystem packages.
v2 also splits the runtime into subpackages of the same module. The core
package no longer depends on net/http:
| Package | Contents |
|---|---|
connectrpc.com/connect/v2 |
Core types, imported by generated code |
connectrpc.com/connect/v2/connecthttp |
net/http server and client bindings |
connectrpc.com/connect/v2/connectproto |
Protobuf codecs |
connectrpc.com/connect/v2/connectgzip |
Gzip compression |
connectrpc.com/connect/v2/connectinprocess |
In-process transport |
v1 wrapped every message in connect.Request[T] or connect.Response[T].
v2 generated code passes protobuf messages directly:
// v1
func (s *pingServer) Ping(
ctx context.Context,
req *connect.Request[pingv1.PingRequest],
) (*connect.Response[pingv1.PingResponse], error) {
return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil
}// v2
func (s *pingServer) Ping(
ctx context.Context,
req *pingv1.PingRequest,
) (*pingv1.PingResponse, error) {
return &pingv1.PingResponse{Text: req.Text}, nil
}Client calls change the same way: no connect.NewRequest wrapper, and no
.Msg field access on the response.
✅ connect-go-v2-migrate handles this.
v1 generated a constructor returning a path and an http.Handler. In v2,
register services on a *connect.Server and mount it with connecthttp:
// v1
mux := http.NewServeMux()
mux.Handle(pingv1connect.NewPingServiceHandler(
&pingServer{},
connect.WithInterceptors(validate.NewInterceptor()),
))// v2
mux := http.NewServeMux()
server := connect.NewServer(validate.NewServerInterceptor())
pingv1connect.RegisterPingServiceHandler(server, &pingServer{})
connecthttp.Mount(mux, server)Interceptors move from connect.WithInterceptors(...) to arguments of
connect.NewServer. Other handler options move to connecthttp.Mount.
In v2, interceptors apply to every service on a server; there is no
per-handler option. Consecutive mux.Handle(...) calls with identical
options share one server. Handlers with different interceptors keep their v1
behavior by registering on separate servers mounted on the same mux.
✅ connect-go-v2-migrate handles the inline mux.Handle(...) shape, grouping
consecutive handlers with identical options onto one server.
v1 clients took an HTTP client and a base URL. v2 clients take a
*connect.Client, which wraps a transport:
// v1
client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080")// v2
client := pingv1connect.NewPingServiceClient(connect.NewClient(
connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))Interceptors move to connect.NewClient. Other client options move to
connecthttp.NewTransport.
✅ connect-go-v2-migrate handles this.
With the wrapper types gone, metadata moves to a *connect.CallInfo carried
on the context. It exposes RequestHeader(), ResponseHeader(), and
ResponseTrailer().
Clients attach the info to the context before the call and read response metadata from it after:
// v1
req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"})
req.Header().Set("X-Request-Id", requestID)
res, err := client.Ping(ctx, req)// v2
ctx, info := connect.NewClientContext(ctx)
info.RequestHeader().Set("X-Request-Id", requestID)
res, err := client.Ping(ctx, &pingv1.PingRequest{Text: "hello"})Handlers get the info from the handler's context:
// v1
func (s *pingServer) Ping(
ctx context.Context,
req *connect.Request[pingv1.PingRequest],
) (*connect.Response[pingv1.PingResponse], error) {
token := req.Header().Get("Authorization")
res := connect.NewResponse(&pingv1.PingResponse{})
res.Header().Set("X-Server-Name", "ping-server")
return res, nil
}// v2
func (s *pingServer) Ping(
ctx context.Context,
req *pingv1.PingRequest,
) (*pingv1.PingResponse, error) {
info, _ := connect.CallInfoForServerContext(ctx)
token := info.RequestHeader().Get("Authorization")
res := &pingv1.PingResponse{}
info.ResponseHeader().Set("X-Server-Name", "ping-server")
return res, nil
}The v1 connect.CallInfoForHandlerContext(ctx) is renamed to
connect.CallInfoForServerContext(ctx); both return (CallInfo, bool), so
existing comma-ok call sites carry over unchanged.
✅ connect-go-v2-migrate moves metadata access to the CallInfo:
- A handler's request-header read (
req.Header()) and response header/trailer set (res.Header()/res.Trailer()on aconnect.NewResponseholder) becomeinfo.*on a seededinfo, _ := connect.CallInfoForServerContext(ctx). - A client's request header set on a
connect.NewRequestand response header/trailer read (res.Header()/res.Trailer()) becomeconnect.NewClientContext(ctx)plusinfo.*. connect.CallInfoForHandlerContextrenames toconnect.CallInfoForServerContext.
connect.NewError takes a message string instead of an error. In v2 a
plain error returned from a handler is never serialized to the wire, so
internal details can't leak by accident.
// v1
return nil, connect.NewError(connect.CodeInternal, err)// v2
return nil, connect.NewError(connect.CodeInternal, err.Error()).WithCause(err)v1 sent the error's full string to the client, so the tool rewrites to
err.Error() to preserve what callers see today, and attaches err with
WithCause so errors.Is and errors.As still match. When the argument is
a function call no WithCause is added, since repeating the call would
evaluate it twice. Assign it to a variable first if you need errors.Is or
errors.As to match.
Common argument shapes collapse to simpler forms with the same wire message:
connect.NewError(code, errors.New("nope")) // -> connect.NewError(code, "nope")
connect.NewError(code, fmt.Errorf("x %s", a)) // -> connect.Errorf(code, "x %s", a)
connect.NewError(code, nil) // -> connect.NewError(code, "")To hide the underlying error from clients, attach it as a cause instead. The
cause is visible to errors.Is and errors.As on the server but never sent
to the client:
return nil, connect.NewError(connect.CodeInternal, "something went wrong").WithCause(err)Error details keep the v1 shape with the constructor moved to connectproto.
AddDetail becomes the cloning WithDetail builder, and ErrorDetail.Value()
becomes connectproto.UnmarshalErrorDetail:
// v1
cErr := connect.NewError(connect.CodeInternal, err)
if detail, derr := connect.NewErrorDetail(info); derr == nil {
cErr.AddDetail(detail)
}// v2
cErr := connect.NewError(connect.CodeInternal, err.Error()).WithCause(err)
if detail, derr := connectproto.NewErrorDetail(info); derr == nil {
cErr = cErr.WithDetail(detail)
}The wire-error helpers became *connect.Error methods:
| v1 | v2 |
|---|---|
connect.NewWireError(code, err) |
connect.NewError(code, msg).WithRemote() |
connect.IsWireError(err) |
cerr, ok := errors.AsType[*connect.Error](err) then cerr.IsRemote() |
Error codes (connect.CodeNotFound, connect.CodeOf, and friends) are
unchanged.
✅ connect-go-v2-migrate handles the NewError rewrite and retargets the
NewErrorDetail/AddDetail guard to connectproto, preserving the v1 wire
message. Switching to WithCause is a behavior change to make by hand, and
ErrorDetail uses are warned for a manual
update.
HTTP-specific options moved from the core package to connecthttp. Most keep
their name and signature and only change package:
// v1
connect.WithReadMaxBytes(1024)
connect.WithSendMaxBytes(2048)
connect.WithCompressMinBytes(512)
connect.WithRequireConnectProtocolHeader()
connect.WithSendGzip()
connect.WithHTTPGet()
connect.WithHTTPGetMaxURLSize(8192, true)
connect.WithProtoJSON()
connect.WithGRPC()
connect.WithGRPCWeb()
connect.WithSendCompression("gzip")// v2
connecthttp.WithReadMaxBytes(1024)
connecthttp.WithSendMaxBytes(2048)
connecthttp.WithCompressMinBytes(512)
connecthttp.WithRequireConnectProtocolHeader()
connecthttp.WithSendGzip()
connecthttp.WithHTTPGet()
connecthttp.WithHTTPGetMaxURLSize(8192, true)
connecthttp.WithProtoJSON()
connecthttp.WithGRPC()
connecthttp.WithGRPCWeb()
connecthttp.WithSendCompression("gzip")The ErrorWriter type, NewErrorWriter, and IsNotModifiedError move to
connecthttp the same way, keeping their signatures.
✅ connect-go-v2-migrate handles these.
In v1, the default read limit was unbounded. This meant any caller,
authenticated or not, could force a server to buffer a message of any size. To
improve safety, v2 introduces a default limit of 4 MiB per message, which can
be overridden using WithReadMaxBytes. Messages exceeding this limit fail with
the CodeResourceExhausted error code.
To prevent unexpected runtime behavior changes during upgrades, the migration tool preserves the v1 unbounded behavior by default:
// v2, as the tool writes it
connecthttp.NewTransport(httpClient, baseURL, connecthttp.WithReadMaxBytes(0))
connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0))✅ connect-go-v2-migrate handles this, and
Options whose signature changed are warned with the v2 replacement:
| v1 | v2 |
|---|---|
connect.WithCodec(codec) |
connecthttp.WithCodecs(...connect.Codec), which replaces the default codecs instead of adding to them. List every codec you need in one call, including connectproto.NewBinaryCodec() if you still want it |
connect.WithCompression(name, dec, comp) |
connecthttp.WithCompressors(...connect.Compressor) (a connectgzip-style compressor), which replaces the defaults. The (name, decompressor, compressor) signature is gone |
connect.WithAcceptCompression(name, dec, comp) |
connecthttp.WithCompressors(...connect.Compressor); registered compressors are advertised automatically, and the order is the preference order, most preferred first. The nil constructors that removed one become an empty connecthttp.WithCompressors() |
connect.WithConditionalHandlerOptions(fn) |
connecthttp.WithConditionalOptions(func(connect.Spec) []connecthttp.Option) (callback signature changed) |
connect.NewNotModifiedError(header) |
connecthttp.NewNotModifiedError() (no header argument) |
v1 had one Interceptor interface with separate methods for unary calls,
streaming clients, and streaming handlers. v2 replaces it with two function
types, connect.ClientInterceptor and connect.ServerInterceptor, that wrap
every RPC type uniformly:
func NewServerInterceptor(logger *slog.Logger) connect.ServerInterceptor {
return func(next connect.ServerFunc) connect.ServerFunc {
return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error {
err := next(ctx, spec, stream)
logger.InfoContext(ctx, "rpc completed",
slog.String("procedure", spec.Procedure),
slog.Any("error", err),
)
return err
}
}
}The tool maps ecosystem interceptor constructors where it can:
- ✅
validate.NewInterceptor()becomesvalidate.NewServerInterceptor()orvalidate.NewClientInterceptor(), depending on where it's used. ⚠️ otelconnect.NewInterceptor()becomesotelconnect.NewServerInterceptor()orotelconnect.NewClientInterceptor(), which return an error. Assign the interceptor before constructing the server or client.⚠️ Custom interceptors must be rewritten by hand to the new function types. The tool warns at each one.
Streaming generated code defines a named stream type per RPC (for example,
PingServiceCumSumClientStream) instead of the v1 generics. Stream Send, Receive,
and SendHeaders keep their v1 shape and take no context.Context; the call's
context is bound when the stream is opened.
Stream metadata moves off the stream and onto the CallInfo, since the
generated stream types no longer carry headers. A handler stream's
RequestHeader()/ResponseHeader()/ResponseTrailer() become info.* on a
seeded info, _ := connect.CallInfoForServerContext(ctx); a client stream's
seed a connect.NewClientContext(ctx) and read from the returned info.
✅ connect-go-v2-migrate handles both.
The v1 receive loop, Receive() bool with Msg() and a trailing Err()
check, becomes a Receive() call returning the message and an error. io.EOF
marks the end of the stream:
// v1
for stream.Receive() {
total += stream.Msg().Number
}
if err := stream.Err(); err != nil {
return nil, err
}// v2
for {
msg, err := stream.Receive()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
total += msg.Number
}✅ connect-go-v2-migrate handles this.
On the client side, stream constructors return an error and the close methods are renamed:
| v1 | v2 |
|---|---|
stream := client.CumSum(ctx) |
stream, err := client.CumSum(ctx) |
stream.CloseRequest() |
stream.CloseSend() |
stream.CloseResponse() |
stream.Close() |
res, err := stream.CloseAndReceive() |
res, err := stream.CloseAndReceive() (returns the bare message) |
✅ connect-go-v2-migrate handles this.
Handler stream parameters change the same way. A v1 handler takes a generic
like *connect.BidiStream[Req, Res], while v2 passes a named type generated
per RPC (like PingServiceCumSumServerStream):
// v1
func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error
// v2
func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error✅ connect-go-v2-migrate resolves the parameter type to the generated stream
type by matching the handler to its RPC.
Some shared helpers need a new sibling API rather than an in-place rewrite,
for example a helper that returns a v1 UnaryInterceptorFunc becoming a v2
ServerInterceptor.
Each ecosystem package releases a connect-go v2 build. grpchealth and
grpcreflect move to /v2 module paths. Most reshape their API to register on a *connect.Server, so
interceptors and alternative transports cover them. The sections below show
each change.
validate.NewInterceptor splits into server and client forms:
// v1
connect.WithInterceptors(validate.NewInterceptor())// v2
connect.NewServer(validate.NewServerInterceptor())✅ connect-go-v2-migrate handles this, picking the server or client form from
where the interceptor is used.
otelconnect.NewInterceptor also splits into NewServerInterceptor and
NewClientInterceptor. Both still return an error, so assign them before
constructing the server or client:
// v1
interceptor, err := otelconnect.NewInterceptor()// v2
serverInterceptor, err := otelconnect.NewServerInterceptor()authn stays on v1. It is plain net/http middleware, so it keeps working in
front of a v2 server without changes. The tool leaves authn imports unchanged.
The health service registers on the server instead of wrapping a mux:
// v1
mux.Handle(grpchealth.NewHandler(checker))// v2
grpchealth.Register(server, checker)✅ connect-go-v2-migrate handles the inline mux.Handle(...) shape. A health
handler registered alongside your services with the same options joins their
server; with different options it gets its own server, keeping its v1
interceptor behavior.
The health client changes like the generated clients:
// v1
client := grpchealth.NewClient(http.DefaultClient, "http://localhost:8080")// v2
client := grpchealth.NewClient(connect.NewClient(
connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))✅ connect-go-v2-migrate handles this.
Reflection registers on the server with one call. Register serves both the
v1 and v1alpha reflection APIs, and by default describes the services
registered on the server, so the static service list usually disappears:
// v1
reflector := grpcreflect.NewStaticReflector("acme.user.v1.UserService")
mux.Handle(grpcreflect.NewHandlerV1(reflector))
mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))// v2
grpcreflect.Register(server)To expose a different set of services, for example when proxying, pass
grpcreflect.WithNamer.
NewHandlerV1, NewHandlerV1Alpha, and
NewStaticReflector call; collapse them to one Register by hand.
The reflection client changes like the generated clients:
// v1
client := grpcreflect.NewClient(http.DefaultClient, "http://localhost:8080")// v2
client := grpcreflect.NewClient(connect.NewClient(
connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"),
))✅ connect-go-v2-migrate handles this.
ClientStream.Close returns only an error. WithRequestHeaders and the
stream's Spec, Peer, and ResponseHeader methods are removed. Set and read
metadata through the context passed to NewStream:
// v1
stream := client.NewStream(ctx, grpcreflect.WithRequestHeaders(header))
_, err := stream.Close()// v2
ctx, info := connect.NewClientContext(ctx)
info.RequestHeader().Set("X-Test", "1")
stream := client.NewStream(ctx)
err := stream.Close()✅ connect-go-v2-migrate drops the unused header result of Close.
WithRequestHeaders, at a Close whose header is used,
and at Spec, Peer, and ResponseHeader calls.
REST transcoding mounts the server's REST routes directly; the
Transcoder and Service types are gone. Methods registered on the server
whose descriptors carry a google.api.http annotation become REST endpoints:
// v1
services := []*vanguard.Service{
vanguard.NewService(pingv1connect.PingServiceName, handler),
}
transcoder, err := vanguard.NewTranscoder(services)
mux.Handle("/", transcoder)// v2
err := vanguard.Mount(mux, server)For gRPC servers, vanguardgrpc.NewTranscoder(grpcServer) becomes
vanguardgrpc.NewServiceRegistrar(server), which registers gRPC service
implementations on a *connect.Server.
v2 clients are not tied to HTTP, so tests no longer need a listener or an
httptest.Server. The connectinprocess package connects a client directly
to a server in the same process:
func TestPingService(t *testing.T) {
server := connect.NewServer()
pingv1connect.RegisterPingServiceHandler(server, &pingServer{})
client := connect.NewClient(connectinprocess.New(server))
pingClient := pingv1connect.NewPingServiceClient(client)
res, err := pingClient.Ping(t.Context(), &pingv1.PingRequest{Text: "hello"})
if err != nil {
t.Fatalf("Ping: %v", err)
}
if got, want := res.GetText(), "hello"; got != want {
t.Errorf("Text = %q, want %q", got, want)
}
}This is not a required migration step, but in-process tests are faster and less flaky than tests over a loopback listener.
If your migration hits a case not covered here, or the tool produces a result you didn't expect, please open an issue or ask in Slack.