Publish and receive Pub/Sub messages from Go with the v2 client library, handle acks, flow control, retries and dead-letter topics, and get the same topics and subscriptions from Go declarations that run locally without the emulator.
Google Cloud Pub/Sub is the managed message bus on GCP: a service publishes to a topic, every subscription on that topic receives a copy, and Pub/Sub redelivers until a subscriber acknowledges. The Go client is efficient, batching publishes and streaming pulls by default, and its behavior around ack deadlines, flow control and redelivery decides whether a consumer is reliable or quietly processes messages twice.
This guide covers the v2 Go client for publishing and receiving, the settings that matter in production, dead-letter topics and local testing, and then shows how Encore (a tool that works alongside your framework) provisions the same setup in your GCP project from Go declarations.
The Pub/Sub v2 Go client authenticates with Application Default Credentials, so the same code uses your gcloud login locally and the service account of Cloud Run or GKE in production:
package main
import (
"context"
"encoding/json"
"log"
"cloud.google.com/go/pubsub/v2"
)
type OrderPlaced struct {
OrderID string `json:"order_id"`
}
func main() {
ctx := context.Background()
client, err := pubsub.NewClient(ctx, "my-project")
if err != nil {
log.Fatal(err)
}
defer client.Close()
pub := client.Publisher("order-placed")
defer pub.Stop() // flushes batched messages
data, _ := json.Marshal(OrderPlaced{OrderID: "ord_123"})
id, err := pub.Publish(ctx, &pubsub.Message{Data: data}).Get(ctx)
if err != nil {
log.Fatal(err)
}
log.Println("published", id)
}
Publish returns immediately and batches messages in the background, and Get waits for the server to confirm. In a request handler, waiting on Get before responding tells you the event was accepted; in a bulk job, collect the results and check them at the end. Create the topic beforehand with gcloud pubsub topics create order-placed or your infrastructure tooling.
sub := client.Subscriber("order-placed-email")
sub.ReceiveSettings.MaxOutstandingMessages = 50
err = sub.Receive(ctx, func(ctx context.Context, m *pubsub.Message) {
var ev OrderPlaced
if err := json.Unmarshal(m.Data, &ev); err != nil {
m.Ack() // malformed: retrying will not help
return
}
if err := sendConfirmation(ctx, ev.OrderID); err != nil {
m.Nack() // redeliver later
return
}
m.Ack()
})
Receive blocks until the context is cancelled and calls the callback from many goroutines at once, so the callback must be safe for concurrent use. MaxOutstandingMessages caps how many unacknowledged messages the subscriber holds, which is the setting that protects a database or a rate-limited API behind the handler. The client extends ack deadlines automatically while the callback runs, up to a maximum extension, and a message that is neither acked nor nacked in that time is redelivered.
Delivery is at least once, so a message can arrive twice even after a successful ack. Make the handler idempotent, for example with an insert that uses ON CONFLICT DO NOTHING on the order ID. Subscriptions can enable exactly-once delivery, which narrows duplicates within a region at some cost in throughput and latency.
Nacked or expired messages are redelivered right away unless the subscription has a retry policy with exponential backoff, which you set on the subscription with a minimum and maximum backoff. A dead-letter topic with a maximum of between 5 and 100 delivery attempts moves messages that keep failing out of the way. The Pub/Sub service account needs publisher permission on the dead-letter topic and subscriber permission on the source subscription for forwarding to work, which is a common reason dead-lettering silently does nothing.
The Pub/Sub emulator runs with gcloud beta emulators pubsub start, and the Go client connects to it when PUBSUB_EMULATOR_HOST is set. Topics and subscriptions have to be created in the emulator on each start, and it does not support every feature of the service, so failure handling is best checked in a real project.
Each topic, subscription, retry policy, dead-letter topic and IAM binding has to exist in every environment, usually through Terraform, and stay in sync with the topic and subscription IDs hard-coded in Go. The receive loop needs a long-running process, and local development needs the emulator plus a setup script.
Run in your terminal to get started locally.
Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. A Pub/Sub topic and its subscriptions are package-level declarations in Go, and on GCP Encore provisions them as Pub/Sub topics and subscriptions with dead-letter topics in your project, with the handler called for each message and no receive loop to run.

// orders/orders.go
package orders
import (
"context"
"time"
"encore.dev/pubsub"
"encore.dev/rlog"
)
type OrderPlaced struct {
OrderID string `json:"order_id"`
}
var OrderPlacedTopic = pubsub.NewTopic[*OrderPlaced]("order-placed", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
type PlaceParams struct {
OrderID string `json:"order_id"`
}
//encore:api public method=POST path=/orders
func Place(ctx context.Context, p *PlaceParams) error {
_, err := OrderPlacedTopic.Publish(ctx, &OrderPlaced{OrderID: p.OrderID})
return err
}
var _ = pubsub.NewSubscription(OrderPlacedTopic, "send-confirmation",
pubsub.SubscriptionConfig[*OrderPlaced]{
Handler: sendConfirmation,
MaxConcurrency: 50,
AckDeadline: time.Minute,
RetryPolicy: &pubsub.RetryPolicy{MaxRetries: 10},
},
)
// Returning an error nacks the message; after 10 retries it goes to the dead-letter topic.
func sendConfirmation(ctx context.Context, ev *OrderPlaced) error {
rlog.Info("sending confirmation", "order_id", ev.OrderID)
return nil
}
encore run delivers messages with an in-memory implementation, so publishing and handling work locally without the emulator, a GCP project or setup scripts, and the local development dashboard shows the publish and the handler in its traces. MaxConcurrency, AckDeadline and RetryPolicy map to the settings covered above, and the handler is a plain function you can call from a test run with encore test, as described in the testing docs.
On deploy to GCP, Encore provisions the topic, the subscription and its dead-letter topic, runs the service on Cloud Run or GKE with its own service account, as listed in the GCP infrastructure docs. The same code deploys to SNS and SQS on AWS. A coding agent adding a consumer writes one NewSubscription in the service that needs it.
Best fit: Go teams on GCP who want Pub/Sub with retries and dead-lettering in their own project, and local development without the emulator.
Create a client with pubsub.NewClient(ctx, projectID) from cloud.google.com/go/pubsub/v2, get a publisher with client.Publisher(topicID), call Publish(ctx, &pubsub.Message{Data: data}) and wait on the returned result with Get(ctx), which returns the server-assigned message ID. Call Stop on the publisher before exiting to flush pending messages.
Get a subscriber with client.Subscriber(subscriptionID) and call Receive(ctx, func(ctx context.Context, m *pubsub.Message) { ... }). Receive blocks and calls the function concurrently for incoming messages. Call m.Ack() after successful processing and m.Nack() to have the message redelivered.
Version 2, released in 2025 as cloud.google.com/go/pubsub/v2, replaces Topic and Subscription handles for data operations with Publisher and Subscriber types, and moves creating and managing topics and subscriptions to generated admin clients on the main client.
Declare a topic with pubsub.NewTopic and a subscription with pubsub.NewSubscription and a Handler from encore.dev/pubsub. encore run delivers messages locally without the emulator, and on GCP Encore provisions the topic, subscription and dead-letter topic in your project.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.