The Go Mental Model
Go requires you to think differently than most languages. If you come from Python, Java, or C++, some things will feel strange or even missing. That is by design. Go's mental model prioritizes explicitness, simplicity, and readability over expressiveness and abstraction. Understanding this model early prevents months of frustration.
No Inheritance, Composition Instead
Go has no class hierarchy. There is no extends, no super, no method resolution order. If you want to reuse behavior, you embed one struct inside another.
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.Prefix, msg)
}
type Server struct {
Logger // Embedded -- Server now has a Log method
Port int
}
func main() {
s := Server{
Logger: Logger{Prefix: "HTTP"},
Port: 8080,
}
s.Log("starting up") // Calls Logger.Log directly
}
[HTTP] starting up
This is not inheritance. There is no polymorphism through a class tree. The Server struct contains a Logger and its methods get promoted to the outer struct. If you need polymorphism, you use interfaces -- which are satisfied implicitly, not declared.
type Writer interface {
Write([]byte) (int, error)
}
// Any type with a Write method satisfies Writer.
// No "implements" keyword needed.
This is a fundamental shift. Instead of designing type hierarchies up front, you define small interfaces where you need them and let types satisfy them naturally.
No Exceptions, Errors Are Values
Go has no try/catch/finally. Functions that can fail return an error as the last return value. You check it immediately.
file, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("opening config: %w", err)
}
defer file.Close()
This looks verbose if you are used to exceptions. But it has a major advantage: the control flow is always visible. There are no hidden jumps. When you read a function top to bottom, you can see every place it might fail and exactly what happens when it does.
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
Every possible failure is handled explicitly. No exception can fly past you unnoticed.
Exported vs Unexported: The Case Convention
Go uses capitalization instead of access modifiers. An identifier starting with an uppercase letter is exported (public). A lowercase letter means unexported (private to the package).
package auth
// Token is exported -- other packages can use it
type Token struct {
Value string // Exported field
ExpiresAt time.Time // Exported field
issuer string // Unexported -- only visible within package auth
}
// NewToken is exported -- this is the constructor
func NewToken(value string) Token {
return Token{
Value: value,
issuer: "auth-service",
}
}
// validate is unexported -- internal helper
func validate(t Token) error {
if t.Value == "" {
return errors.New("empty token")
}
return nil
}
There is no public, private, or protected keyword. The first letter tells you everything. This convention is enforced by the compiler, not by a linter.
Zero Values: Everything Has a Default
In Go, every type has a zero value. When you declare a variable without initializing it, it gets the zero value for its type. These are not random -- they are useful defaults.
var i int // 0
var f float64 // 0.0
var s string // "" (empty string)
var b bool // false
var p *int // nil
var sl []int // nil (but usable -- you can append to it)
var m map[string]int // nil (not usable until initialized with make)
This design means you can often use the zero value directly without explicit initialization:
type Counter struct {
mu sync.Mutex
count int
}
// No constructor needed. The zero value is a valid, unlocked counter.
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func main() {
var c Counter // Ready to use immediately
c.Increment()
}
The sync.Mutex zero value is an unlocked mutex. The int zero value is 0. The bytes.Buffer zero value is an empty buffer ready for writes. This is intentional -- Go types are designed so the zero value is useful.
// bytes.Buffer works immediately at zero value
var buf bytes.Buffer
buf.WriteString("hello")
buf.WriteString(" world")
fmt.Println(buf.String()) // "hello world"
The Semicolons Are There
Go uses semicolons to terminate statements, but you never type them. The lexer inserts them automatically based on simple rules: if a line ends with an identifier, literal, or one of a few specific tokens (break, continue, return, ), }), a semicolon is inserted.
This explains some surprising formatting requirements:
// This works:
if err != nil {
return err
}
// This does NOT compile:
if err != nil
{
return err
}
The second version fails because the lexer inserts a semicolon after nil, turning it into if err != nil; which is invalid. This is why Go enforces opening braces on the same line. It is not a style choice -- it is a consequence of the grammar.
The same rule explains why you cannot put the else on its own line:
// Correct:
if x > 0 {
fmt.Println("positive")
} else {
fmt.Println("non-positive")
}
// Does not compile:
if x > 0 {
fmt.Println("positive")
}
else {
fmt.Println("non-positive")
}
No Generics Until 1.18
Go shipped without generics in 2009 and did not add them until March 2022 with Go 1.18. For thirteen years, Go developers used interface{} (the empty interface, satisfied by any type) when they needed generic behavior, and accepted the type assertions that came with it.
// Pre-generics: you lose type safety
func contains(items []interface{}, target interface{}) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
// Post-generics (Go 1.18+): type-safe
func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
The lesson here is cultural, not technical. Go's community spent over a decade writing production software without generics. The patterns that emerged -- code generation, accepting interfaces, returning concrete types -- are still valid. Generics are a tool, not a requirement.
Short Variable Declaration vs var
Go has two ways to declare variables. Use := inside functions for brevity. Use var at the package level or when you want a specific type.
// := infers the type (only inside functions)
name := "Alice"
count := 42
prices := []float64{9.99, 14.99, 24.99}
// var for package-level declarations
var maxRetries = 3
// var when you need a specific type
var ratio float32 = 0.5 // := would infer float64
// var for zero-value initialization
var wg sync.WaitGroup
Packages Are Not Classes
A Go package is a directory of .go files with the same package declaration. Packages are not classes. They are namespaces that group related functionality.
// Good: package names are short, lowercase, single-word
package auth
package store
package handler
// Bad: package names should not be generic or stutter
package utils // Too vague -- what is a "util"?
package authpkg // Stutters: authpkg.Authenticate
When designing a package, think about what it provides, not what object it represents. A package named user that exports user.New(), user.Validate(), and user.Store() reads better than a class.
Common Pitfalls
- Trying to write Java in Go. Do not create one-method interfaces and pair them with one-implementation classes. Go prefers concrete types and small interfaces discovered through usage.
- Overusing pointers. Coming from Java (where everything is a reference) or C (where pointers are everywhere), you might use pointers for everything. In Go, small structs should be passed by value. Use pointers when you need mutation or when the struct is large.
- Ignoring zero values. If you find yourself writing constructors just to set fields to 0, false, or empty string, step back. The zero value already does that.
- Fighting the formatter. If
gofmtputs your code somewhere unexpected, the answer is never to override it. Adjust your mental model to match Go's formatting rules. - Using init() functions. Go supports
func init()for package initialization, but it makes code harder to test and reason about. Prefer explicit initialization inmain(). - Assuming nil slices are broken. A nil slice works with
append,len, andrange. You do not need to initialize every slice withmake.
var items []string // nil, but functional
items = append(items, "a") // works fine
fmt.Println(len(items)) // 1
Key Takeaways
- Go uses composition (embedding) instead of inheritance. Interfaces are satisfied implicitly.
- Errors are return values, not exceptions. Control flow is always visible.
- Uppercase means exported, lowercase means unexported. The compiler enforces this.
- Every type has a useful zero value. Design your types so the zero value works.
- Semicolons are inserted automatically, which is why braces must be on the same line.
- Go operated without generics for 13 years. The language culture values simplicity over abstraction.
- Packages are namespaces, not classes. Name them for what they do, not what they contain.