Skip to content

Instantly share code, notes, and snippets.

@r6m
Last active February 25, 2026 04:04
Show Gist options
  • Select an option

  • Save r6m/922591612a4948205edd9fc053b89d16 to your computer and use it in GitHub Desktop.

Select an option

Save r6m/922591612a4948205edd9fc053b89d16 to your computer and use it in GitHub Desktop.
Writing Idiomatic Go

Writing Idiomatic Go

Title Page

  • Writing Idiomatic Go
  • © 2026 GoCloudStudio
  • Patterns, Practices, and Hard-Won Lessons for Production Go
  • GoCloudStudio Engineering Team
  • February 2026 • Version 1.0

Foreword

Go does not reward cleverness. It rewards clarity. That single idea sits beneath every convention in this document. If you have spent time in languages that celebrate syntactic density or multi-layered abstractions, Go will feel strange at first—almost too plain. Stick with it. The plainness is the point. After a few thousand lines you stop noticing the simplicity and start noticing how rarely you have to re-read a function to understand what it does. We wrote this guide because we kept having the same conversations during code reviews at GoCloudStudio. A junior engineer wraps every return value in a custom Result type. A contractor nests four levels of if-else when the early-return pattern would cut the function in half. Someone names a package "helpers." These are not character flaws; they are habits imported from other ecosystems. Go has its own habits, and they are worth learning deliberately rather than absorbing by osmosis. Nothing here is original. Every idiom traces back to Effective Go, the standard library source, talks by Rob Pike and Russ Cox, or the Go Code Review Comments wiki. Our contribution is organization: we have arranged these ideas in the order we wish someone had shown them to us, with the examples we wish we had seen. Use what helps. Skip what you already know. And when you disagree with something, go read the standard library—you will probably find that it agrees with us.

— The Engineering Team at GoCloudStudio

Table of Contents

1. Naming

Names in Go carry more weight than in most languages. A capital letter decides whether the rest of the world can see your symbol. A package name shows up at every call site. Get naming wrong and the code fights you on every line; get it right and the code practically documents itself.

1.1 Package Names

A package name is a short, lowercase, single-word noun. It doubles as a namespace, so the symbols it exports should read well when qualified. The test: say the call site out loud. If it stutters, fix the name.

Bad: Stutters at the call site

package httputil
// httputil.HTTPUtilClient — the word "httputil" appears twice.
type HTTPUtilClient struct{}

func HTTPUtilGet(url string) {}

Good: Reads like a sentence

package httputil
// httputil.Client — clean qualifier, no stutter.
type Client struct{}

func Get(url string) {}

1.2 Variables, Functions, and Scope

Go has a rule of thumb that trips up people from Python and Java: the shorter the scope, the shorter the name. A loop counter called i is perfect. A package-level timeout called t is a nightmare. Let the scope guide you.

// Tight scope: short names are fine, even preferred.
for i, v := range items {
  process(v)
}

// Broad scope: name carries more context.
var sessionTimeout = 30 * time.Minute

// Acronyms: all-caps or all-lower. Never mixed.
var userID     string       // not userId
var xmlParser  *XMLParser   // not XmlParser
var httpClient *http.Client

1.3 Interfaces: The -er Convention

One method? Name it with -er. If the method is Write, the interface is Writer. If the method is Handle, the interface is Handler. This pattern is everywhere in the standard library and your code should follow it.

type Reader interface {
  Read(p []byte) (n int, err error)
}

type Stringer interface {
  String() string
}

// Multi-method: describe the behavior, not the thing.
type ReadWriteCloser interface {
  Reader
  Writer
  Closer
}

1.4 Exported vs. Unexported

Uppercase initial letter = exported. Lowercase = unexported. That is the entire visibility system. The discipline it demands is simple: export the minimum. Every exported name is a promise to your users. Unexported names are free to change.

type Server struct {
  Addr string           // Exported: users configure this.
  handler http.Handler  // Unexported: internal wiring.
}

func (s *Server) ListenAndServe() error { ... } // Public API.
func (s *Server) trackConn(c net.Conn)  { ... } // Internal.

2. Making Code Read Like English

The best Go code disappears. You read it and forget you are reading code at all—it feels like a description of what the program does, written in plain language. This is not an accident. It comes from naming structs after nouns, methods after verbs, and composing them so the call site tells a story. This chapter is the heart of the guide. Everything else supports it.

2.1 Structs Are Nouns, Methods Are Verbs

A struct represents a thing: an Order, a User, an EmailDispatcher. A method represents an action that thing can take. When you pair them well, the call site reads like a sentence.

// The struct is a noun: an order.
type Order struct {
  ID string
  Customer Customer
  Items []LineItem
  CreatedAt time.Time
  status string
}

// Methods are verbs: things an order can do.
func (o *Order) AddItem(product Product, qty int) {
  o.Items = append(o.Items, LineItem{
    Product: product,
    Quantity: qty,
  })
}

func (o *Order) Total() Money {
  var sum Money
  for _, item := range o.Items {
    sum = sum.Add(item.Subtotal())
  }

  return sum
}

func (o *Order) Cancel(reason string) error {
  if o.status == "shipped" {
    return fmt.Errorf("cannot cancel order %s: already shipped", o.ID)
  }

  o.status = "cancelled"
  return nil
}

// Call site reads like English:
// order.AddItem(keyboard, 2)
// total := order.Total()
// err := order.Cancel("changed my mind")

2.2 Fluent Domain Modeling

When your domain types have well-chosen names, business logic reads like a specification. Compare these two approaches to the same operation:

Bad: Procedural — what does this do?

func processTransaction(uid string, pid string, n int, db *sql.DB) error {
  row := db.QueryRow("SELECT balance FROM accounts WHERE id = ?", uid)
  var bal float64
  row.Scan(&bal)
  row2 := db.QueryRow("SELECT price FROM products WHERE id = ?", pid)
  var pr float64
  row2.Scan(&pr)
  total := pr * float64(n)
  if bal < total {
    return errors.New("insufficient")
  }
  db.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", total, uid)
  db.Exec("INSERT INTO orders ...")
  return nil
}

Good: Domain-driven — reads like a business rule

func (s *CheckoutService) PlaceOrder(ctx context.Context, req PlaceOrderReq) error {
  buyer, err := s.accounts.FindByID(ctx, req.BuyerID)
  if err != nil {
    return fmt.Errorf("find buyer: %w", err)
  }

  cart, err := s.carts.ForUser(ctx, req.BuyerID)
  if err != nil {
    return fmt.Errorf("load cart: %w", err)
  }

  total := cart.Total()
  if !buyer.CanAfford(total) {
    return ErrInsufficientBalance
  }

  if err := buyer.Charge(total); err != nil {
    return fmt.Errorf("charge buyer: %w", err)
  }

  order := NewOrderFrom(buyer, cart)

  return s.orders.Save(ctx, order)
}
// Every line answers a business question:
//   "Find the buyer. Load their cart.
//   Calculate the total. Can they afford it?
//   Charge them. Create the order. Save it."

The readability test Cover the function name with your hand and read only the body. If a non-programmer on your team can follow the logic, you have written idiomatic Go.

2.3 Value Types That Carry Meaning

Primitive types are meaningless on their own. Is that float64 a price, a weight, or a temperature? Wrapping primitives in named types eliminates an entire class of bugs and makes the code selfdocumenting.

// Without named types: what is each float64?
func ApplyDiscount(price float64, discount float64) float64 {
  return price - (price * discount) // is discount 0.1 or 10?
}

// With named types: the call site cannot lie.
type Money struct {
  cents int64 // store as cents to avoid floating point issues
}

type Percent struct {
  basis int // 1000 = 10.00%
}

func (m Money) Apply(p Percent) Money {
  reduction := m.cents * int64(p.basis) / 10000
  return Money{cents: m.cents - reduction}
}

func (m Money) String() string {
  return fmt.Sprintf("$%d.%02d", m.cents/100, m.cents%100)
}

// Now the call site reads:
//  price := Money{cents: 4999}       // $49.99
//  discount := Percent{basis: 1500}  // 15%
//  final := price.Apply(discount)
//  fmt.Println(final)  // $42.49

2.4 Building a Readable Pipeline

Sometimes a single operation spans several steps. Rather than cramming it into one function, split each step into a method on a purpose-built type. The result is a pipeline that reads top to bottom, each line doing exactly one thing.

type EmailCampaign struct {
  recipients []Recipient
  subject string
  body string
  filtered []Recipient
  results []SendResult
}

func NewCampaign(subject, body string, recipients []Recipient) *EmailCampaign {
  return &EmailCampaign{
    recipients: recipients,
    subject: subject,
    body: body,
  }
}

func (c *EmailCampaign) ExcludeUnsubscribed() *EmailCampaign {
  for _, r := range c.recipients {
    if !r.Unsubscribed {
      c.filtered = append(c.filtered, r)
    }
  }
  return c
}

func (c *EmailCampaign) ExcludeBounced() *EmailCampaign {
  var clean []Recipient
  for _, r := range c.filtered {
    if !r.Bounced {
      clean = append(clean, r)
    }
  }
  c.filtered = clean
  return c
}

func (c *EmailCampaign) Send(dispatcher Dispatcher) *EmailCampaign {
  for _, r := range c.filtered {
    result := dispatcher.Send(r.Email, c.subject, c.body)
    c.results = append(c.results, result)
  }
  return c
}

// Usage reads like a plan:
//  campaign := NewCampaign(subject, body, allUsers).
//    ExcludeUnsubscribed().
//    ExcludeBounced().
//    Send(mailer)

2.5 Abstracting Infrastructure Behind Interfaces

Real applications talk to databases, caches, queues, and third-party APIs. If those details leak into your business logic, the code stops reading like a domain description and starts reading like an infrastructure manual. The fix is straightforward: hide the how behind an interface and let the what stay visible.

// Define what you need, not how it works.
type UserRepository interface {
  FindByID(ctx context.Context, id string) (*User, error)
  Save(ctx context.Context, u *User) error
}

type PasswordHasher interface {
  Hash(plain string) (string, error)
  Compare(hashed, plain string) bool
}

// Business logic reads like a specification.
type RegistrationService struct {
  users UserRepository
  hasher PasswordHasher mailer WelcomeMailer
}

func (s *RegistrationService) Register(ctx context.Context, req SignupReq) error {
  existing, err := s.users.FindByID(ctx, req.Email)
  if err == nil && existing != nil {
    return ErrEmailTaken
  }

  hashed, err := s.hasher.Hash(req.Password)
  if err != nil {
    return fmt.Errorf("hash password: %w", err)
  }

  user := &User{
    Email:    req.Email,
    Name:     req.Name,
    Password: hashed,
    JoinedAt: time.Now(),
  }

  if err := s.users.Save(ctx, user); err != nil {
    return fmt.Errorf("save user: %w", err)
  }

  return s.mailer.SendWelcome(ctx, user)
}

// Reading the function body:
//    "Check if the email is taken.
//    Hash the password. Save the user.
//    Send a welcome email."
// No SQL. No SMTP details. No Redis. Just intent.

3. Error Handling

Go makes you type if err != nil more than you will ever enjoy. That is the deal. In return, every error path is visible, every failure mode is explicit, and you never wake up to a stack trace caused by an exception nobody caught. It is a good deal.

3.1 Never Swallow Errors

The underscore is Go’s trap door. Using _ to discard an error should be as rare as a panic—and equally well-justified when it appears.

Bad: Silent failure waiting to ruin your weekend

data, _ := json.Marshal(user)
w.Write(data) // if Marshal fails, data is nil

Good: Every failure has a name

data, err := json.Marshal(user)
if err != nil {
  return fmt.Errorf("marshal user %d: %w", user.ID, err)
}
if _, err := w.Write(data); err != nil {
  return fmt.Errorf("write response: %w", err)
}

3.2 Wrap with Context Using %w

A bare error like "permission denied" is useless at 3 AM. Wrapping it with context turns it into a breadcrumb trail: "save invoice: write to S3: put object: permission denied." Now you know where to look.

func (s *InvoiceService) Save(ctx context.Context, inv *Invoice) error {
  pdf, err := s.renderer.Render(inv)
  if err != nil {
    return fmt.Errorf("render invoice %s: %w", inv.ID, err)
  }

  if err := s.storage.Put(ctx, inv.StoragePath(), pdf); err != nil {
    return fmt.Errorf("save invoice %s to storage: %w", inv.ID, err)
  }

  return nil
}
// Resulting error:
// "save invoice INV-2026-0042 to storage: put object: permission denied"

3.3 Sentinel Errors and Custom Types

When callers need to branch on specific failures, give them something to check. Sentinel errors work for simple conditions. Custom error types work when the caller needs structured detail.

// Sentinel: callers check with errors.Is.
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")

// Custom type: callers inspect with errors.As.
type ValidationError struct {
  Field string
  Message string
}

func (e *ValidationError) Error() string {
  return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

// Caller:
var ve *ValidationError
if errors.As(err, &ve) {
  log.Printf("bad field %q: %s", ve.Field, ve.Message)
}

3.4 Happy Path Stays Left

Indent is a cost. Every level of nesting is a tax on the reader. Idiomatic Go keeps the success path at the left margin by returning early on errors.

Bad: Nested pyramid of doom

func process(r *http.Request) error {
  data, err := io.ReadAll(r.Body)
  if err == nil {
    var p Payload
    err = json.Unmarshal(data, &p)
    if err == nil {
      err = validate(p)
      if err == nil {
        return save(p)
      }
    }
  }
  return err
}

Good: Flat, scannable, early returns

func process(r *http.Request) error {
  data, err := io.ReadAll(r.Body)
  if err != nil {
    return fmt.Errorf("read body: %w", err)
  }

  var p Payload
  if err := json.Unmarshal(data, &p); err != nil {
    return fmt.Errorf("unmarshal: %w", err)
  }

  if err := validate(p); err != nil {
    return fmt.Errorf("validate: %w", err)
  }

  return save(p)
}

4. Interfaces

Interfaces in Go are implicit—a type satisfies an interface by implementing its methods, with no "implements" keyword. This small design choice has enormous consequences. It means the consumer defines the contract, not the provider. It means you can write a function that works with any type that can Read, without the type author ever having heard of your function.

4.1 Accept Interfaces, Return Concrete Types

This is one of the most quoted Go proverbs, and for good reason. When a function accepts an interface, it works with anything that fits. When it returns a concrete type, the caller gets full access to fields and methods.

// This function works with files, HTTP bodies, strings, buffers,
// network connections—anything that implements io.Reader.
func CountLines(r io.Reader) (int, error) {
  scanner := bufio.NewScanner(r)
  n := 0

  for scanner.Scan() {
    n++
  }

  return n, scanner.Err()
}

// All of these work:
lines, _ := CountLines(os.Stdin)
lines, _ = CountLines(strings.NewReader("hello\nworld"))
lines, _ = CountLines(resp.Body)

4.2 Keep Them Small

The io.Reader interface has one method. The fmt.Stringer interface has one method. These are among the most widely implemented interfaces in the Go ecosystem. There is a lesson in that. A large interface is a weak abstraction because few types can satisfy it. A small interface is a powerful one.

// Small and composable.
type Sender interface {
  Send(ctx context.Context, to string, msg Message) error
}

type Receiver interface {
  Receive(ctx context.Context) (Message, error)
}

// Composed when you need both.
type Transceiver interface {
  Sender
  Receiver
}

4.3 Define at the Consumer, Not the Provider

This is the opposite of Java. In Go, the package that depends on a behavior defines the interface describing that behavior. The providing package just exports a concrete struct. This keeps dependencies narrow and coupling low.

// package billing: defines only what it needs.
package billing

type InvoiceStore interface {
  GetUnpaid(ctx context.Context, accountID string) ([]Invoice, error)
}

type Service struct {
  invoices InvoiceStore
}

// package postgres: provides the full implementation.
package postgres

type InvoiceRepo struct{ db *sql.DB }

func (r *InvoiceRepo) GetUnpaid(ctx context.Context, id string) ([]Invoice, error) { ... }
func (r *InvoiceRepo) GetAll(ctx context.Context) ([]Invoice, error)
{ ... }
func (r *InvoiceRepo) MarkPaid(ctx context.Context, id string) error
{ ... }
// billing.Service never sees GetAll or MarkPaid.
// It depends only on the single method it actually calls.

5. Constructors and Configuration

5.1 The New Convention

Go has no constructors. You write a function called New (if the package has one primary type) or NewXxx (if there are several). If construction can fail, return an error.

package cache

type Cache struct {
  ttl time.Duration
  store map[string]entry mu sync.RWMutex
}

func New(ttl time.Duration) *Cache {
  return &Cache{
    ttl:   ttl,
    store: make(map[string]entry),
  }
}

// When construction can fail: package db
func NewPool(dsn string) (*Pool, error) {
  conn, err := sql.Open("postgres", dsn)
  if err != nil {
    return nil, fmt.Errorf("open db: %w", err)
  }
  return &Pool{conn: conn}, nil
}

5.2 Functional Options

When a type has five or more knobs, a constructor with five parameters is unreadable. The functional options pattern solves this. Each option is a self-describing function. New options can be added without breaking existing callers.

type Server struct {
  addr         string
  readTimeout  time.Duration
  writeTimeout time.Duration
  maxBodySize  int64
  logger       *slog.Logger
}

type Option func(*Server)

func WithReadTimeout(d time.Duration) Option {
  return func(s *Server) { s.readTimeout = d }
}
func WithWriteTimeout(d time.Duration) Option {
  return func(s *Server) { s.writeTimeout = d }
}
func WithMaxBodySize(n int64) Option {
  return func(s *Server) { s.maxBodySize = n }
}
func WithLogger(l *slog.Logger) Option {
  return func(s *Server) { s.logger = l }
}

func NewServer(addr string, opts ...Option) *Server {
  s := &Server{
    addr: addr,
    readTimeout: 5 * time.Second,
    writeTimeout: 10 * time.Second,
    maxBodySize: 1 << 20, // 1 MB
    logger: slog.Default()
  }

  for _, apply := range opts {
    apply(s)
  }

  return s
}

// The call site documents itself:
srv := NewServer(":8080",
  WithReadTimeout(3*time.Second),
  WithMaxBodySize(10<<20),
  WithLogger(appLogger),
)

5.3 Pointer vs. Value Receivers

Pointer receiver if the method mutates the receiver, if the struct is large, or if consistency demands it (one pointer method on a type means all methods should use pointers). Value receiver for small, immutable types. Mixing receiver types on the same struct is a code smell.

// Pointer: mutates state.
func (s *Server) Shutdown(ctx context.Context) error {
  s.mu.Lock()
  defer s.mu.Unlock()
  s.running = false
  return s.listener.Close()
}

// Value: small, immutable, safe to copy.
type Coordinate struct{ Lat, Lng float64 }

func (c Coordinate) DistanceTo(other Coordinate) float64 {
  // Haversine formula...
}

6. Concurrency

Goroutines are cheap. Debugging leaked goroutines is not. The three questions to ask before writing go func(): how does it stop, who owns the channel, and what happens if it panics?

6.1 Goroutine Lifecycle

Every goroutine you start must have a clear shutdown path. The errgroup package from x/sync is the standard tool for this: it launches goroutines, collects errors, and cancels siblings on the first failure.

func (s *Scraper) FetchAll(ctx context.Context, urls []string) ([]Page, error) {
  g, ctx := errgroup.WithContext(ctx)
  pages := make([]Page, len(urls))

  for i, url := range urls {
    g.Go(func() error {
      page, err := s.fetch(ctx, url)
      if err != nil {
      return fmt.Errorf("fetch %s: %w", url, err)
      }
      pages[i] = page // safe: each goroutine writes to its own index

      return nil
    })
  }

  if err := g.Wait(); err != nil {
    return nil, err
  }
  return pages, nil
}

6.2 Channels: Sender Closes, Receiver Ranges

The sender is responsible for closing the channel. The receiver ranges over it. Reversing this invariant leads to panics on send-to-closed-channel.

func produce(ctx context.Context, items []Item) <-chan Result {
  out := make(chan Result)
  go func() {
    defer close(out) // Sender closes.
    for _, item := range items {
      select {
      case out <- process(item):
      case <-ctx.Done():
        return
      }
    }
  }()
  return out
}

// Consumer ranges until the channel closes.
for result := range produce(ctx, items) {
  fmt.Println(result)
}

6.3 Protecting Shared State

When channels are the wrong shape for the problem, use sync.Mutex. Keep the lock scope as small as possible. Prefer RWMutex when reads dominate.

type RateLimiter struct {
  mu sync.Mutex
  tokens int
  capacity int
  lastFill time.Time
}

func (r *RateLimiter) Allow() bool {
  r.mu.Lock()
  defer r.mu.Unlock()

  r.refill()
  if r.tokens > 0 {
    r.tokens--
    return true
  }
  return false
}

7. Package Design

7.1 Organize by Domain, Not by Kind

A models/ package is a code smell. A utils/ package is a cry for help. These groupings say nothing about what the code does and inevitably become junk drawers. Organize by business domain instead.

Bad: Organized by kind: meaningless structure

models/
  user.go
  order.go
handlers/
  user_handler.go
order_handler.go
  utils/
  validation.go

Good: Organized by domain: cohesive units

user/
  user.go     // types + Store interface
  service.go  // business rules
  handler.go  // HTTP layer
order/
  order.go
  service.go
  handler.go

7.2 Use internal/ to Control Boundaries

The internal/ directory is enforced by the Go toolchain. Code inside it can only be imported by its parent tree. Use it to share helpers between your own packages without leaking them to external consumers.

myapp/
  cmd/server/main.go // can import internal/auth
  internal/
    auth/token.go    // shared within myapp only
  user/service.go    // can import internal/auth
  go.mod

7.3 Kill Global State

Package-level variables and init() functions are invisible dependencies. They make packages untestable, unsafe for concurrent use, and impossible to configure differently in different contexts. Pass everything through constructors.

Bad: Hidden global dependency

var db *sql.DB

func init() {
  var err error
  db, err = sql.Open("postgres", os.Getenv("DB_URL"))
  if err != nil { log.Fatal(err) }
}

func GetUser(id string) (*User, error) {
  return queryUser(db, id) // which db? can't swap in tests
}

Good: Explicit, testable, configurable

type UserStore struct{ db *sql.DB }

func NewUserStore(db *sql.DB) *UserStore {
  return &UserStore{db: db}
}

func (s *UserStore) GetUser(ctx context.Context, id string) (*User, error) {
  return queryUser(ctx, s.db, id)
}

8. Testing

Go ships with a test runner that needs no third-party framework. Tests live next to the code they test, in files ending in _test.go. No magic. No annotations. Just functions that start with Test.

8.1 Table-Driven Tests

This is the dominant Go testing pattern. You define a slice of test cases, loop over them, and run each as a subtest. Adding a new edge case is one line.

func TestSlugify(t *testing.T) {
	tests := []struct {
		name string

		input string
		want  string
	}{
		{name: "simple", input: "Hello World", want: "hello-world"},
		{name: "special chars", input: "Go @ 100%!", want: "go-at-100"},
		{name: "dashes", input: "already-slugged", want: "already-slugged"},
		{name: "unicode", input: "café latté", want: "cafe-latte"},
		{name: "empty", input: "", want: ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Slugify(tt.input)
			if got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

8.2 Fakes Over Mocks

Because interfaces are defined at the consumer, you rarely need a mocking library. A simple struct that implements the interface is a fake, and it is easier to read, easier to debug, and compiles at type-check time.

// In production: 
type Notifier interface {
	Notify(ctx context.Context, userID string, msg string) error
}

// In tests: a simple fake.
type spyNotifier struct {
	calls []string
}

func (s *spyNotifier) Notify(_ context.Context, uid, msg string) error {
	s.calls = append(s.calls, uid+":"+msg)
	return nil
}
func TestOrderConfirmation(t *testing.T) {
	spy := &spyNotifier{}
	svc := NewOrderService(spy)
	svc.Place(ctx, order)
	if len(spy.calls) != 1 {
		t.Fatalf("got %d notifications, want 1", len(spy.calls))
	}
	if !strings.Contains(spy.calls[0], order.ID) {
		t.Errorf("notification %q missing order ID %s", spy.calls[0], order.ID)
	}
}

8.3 t.Helper() Saves Your Sanity

Mark helper functions with t.Helper() so failures point to the test case, not to a line inside a utility function ten frames deep.

func mustParseJSON(t *testing.T, raw string) map[string]any {
  t.Helper()
  var m map[string]any
  if err := json.Unmarshal([]byte(raw), &m); err != nil {
    t.Fatalf("invalid JSON %q: %v", raw, err)
  }
  return m
}

9. Context

Three rules, no exceptions. First: context.Context is always the first parameter, always named ctx. Second: never store a context in a struct field. Third: never pass nil; use context.TODO() as a placeholder.

func (s *PaymentService) Charge(ctx context.Context, req ChargeReq) (*Receipt, error) {
// Create a child context with a 5-second deadline.
  ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
  defer cancel()

  token, err := s.gateway.Tokenize(ctx, req.Card)
  if err != nil {
    return nil, fmt.Errorf("tokenize card: %w", err)
  }
  
  receipt, err := s.gateway.Capture(ctx, token, req.Amount)
  if err != nil {
    return nil, fmt.Errorf("capture payment: %w", err)
  }

  return receipt, nil
}

10. Structured Logging

The log package from the standard library prints unstructured text. Since Go 1.21, the log/slog package gives you structured, leveled logging out of the box. Use it in all new code.

func (s *OrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	logger := slog.With(
		"method",     r.Method,
		"path",       r.URL.Path,
		"remote_ip",  r.RemoteAddr,
		"request_id", r.Header.Get("X-Request-ID"),
	)
	logger.Info("request received")
  
	order, err := s.service.Process(r.Context(), r)
	if err != nil {
		logger.Error("processing failed", "error", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	logger.Info("order created", "order_id", order.ID, "total", order.Total)
}

11. Everyday Patterns

11.1 Slices: Pre-allocate When You Know the Size

// You know the length: pre-allocate.
names := make([]string, 0, len(users))
for _, u := range users {
  names = append(names, u.Name)
}

// You don't know the length: let append grow.
var errors []string
if name == "" {
  errors = append(errors, "name is required")
}
if age < 0 {
  errors = append(errors, "age cannot be negative")
}

11.2 Defer for Cleanup

Defer guarantees cleanup when the function returns, regardless of which return path is taken. Pair every resource acquisition with a deferred release.

func queryReport(ctx context.Context, db *sql.DB) (*Report, error) {
  tx, err := db.BeginTx(ctx, nil)
  if err != nil {
    return nil, err
  }
  defer func() {
    if err != nil {
    tx.Rollback()
  }
  }()

  // ... run queries using tx ...

  err = tx.Commit()
  return &report, err
}

11.3 Generics: Use Sparingly

Generics landed in Go 1.18. They shine for utility functions that operate on multiple types. They hurt when used for domain types that do not actually vary. The test: can you name three concrete types that would use this generic? If not, write concrete code.

// Good: works for int, float64, string, or any ordered type.
func Max[T cmp.Ordered](a, b T) T {
  if a > b {
    return a
  }
  return b
}

// Good: eliminates repetitive filter functions.
func Filter[T any](items []T, keep func(T) bool) []T {
  result := make([]T, 0, len(items))
  for _, item := range items {
    if keep(item) {
      result = append(result, item)
    }
  }
  return result
}

adults := Filter(users, func(u User) bool { return u.Age >= 18 })
primes := Filter(numbers, isPrime)

12. Tooling and Style

Go has the strongest formatting culture in any mainstream language. There is one format, enforced by a machine, and no team has ever spent a meeting arguing about brace placement. Embrace it.

12.1 The Non-Negotiables

# These run in CI. No exceptions. 
gofmt -w .        # Format everything.
go vet ./...      # Catch common mistakes.
golangci-lint run # 50+ linters in one pass.

12.2 Godoc Comments

Every exported symbol gets a comment that starts with its name. These comments are not decoration; they are the source material for godoc.

// Dispatcher sends notifications across multiple channels.
// It is safe for concurrent use.
type Dispatcher struct {
  channels []Channel 
  mu sync.RWMutex 
}

// Send delivers msg to all registered channels. It returns
// the first error encountered, if any.
func (d *Dispatcher) Send(ctx context.Context, msg Message) error {
  ...
}

Quick Reference

Summary

Topic Idiomatic Rule
Naming Short, camelCase. Package name qualifies the export. No stutter.
Readability Structs = nouns, methods = verbs. Call sites read like sentences.
Errors Handle every one. Wrap with %w. Happy path stays left.
Interfaces Small. Defined at the consumer. Accept interfaces, return structs.
Constructors New or NewXxx. Functional options for 5+ knobs.
Concurrency errgroup for lifecycle. Sender closes the channel. Lock small.
Packages Organize by domain. Use internal/. Zero global state.
Testing Table-driven. Fakes, not mocks. Always t.Helper().
Context First param. Named ctx. Never in a struct.
Logging slog for structured key-value logging. No fmt.Println.
Tooling gofmt + go vet + golangci-lint. Always. In CI.
Generics Only when 3+ types benefit. Concrete code first.

Closing

Go is a language that respects the reader more than the writer. It asks you to be explicit about errors, conservative with exports, and deliberate with concurrency. It gives you almost no syntax to impress your colleagues with. What it gives you instead is code that a new hire can read on day one and a production system can run for years without a rewrite. The idioms in this guide are not rules handed down from a committee. They emerged from real codebases under real load, maintained by real teams who had to debug them at 3 AM. They work because they prioritize the long game—months and years of maintenance—over the short thrill of a clever one-liner. Write boring Go. Your future self will thank you. Published by GoCloudStudio • Gurugram, India • gocloudwithus@gmail.com © 2026 GoCloudStudio. All rights reserved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment