- Writing Idiomatic Go
- © 2026 GoCloudStudio
- Patterns, Practices, and Hard-Won Lessons for Production Go
- GoCloudStudio Engineering Team
- February 2026 • Version 1.0
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
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.
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) {}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.ClientOne 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
}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.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.
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")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.
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.49Sometimes 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)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.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.
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 nilGood: 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)
}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"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)
}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)
}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.
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)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
}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.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
}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),
)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...
}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?
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
}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)
}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
}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.goGood: 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.goThe 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.modPackage-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)
}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.
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)
}
})
}
}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)
}
}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
}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
}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)
}// 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")
}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
}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)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.
# These run in CI. No exceptions.
gofmt -w . # Format everything.
go vet ./... # Catch common mistakes.
golangci-lint run # 50+ linters in one pass.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
| 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. |
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.