How to Write Idiomatic Go Code: Principles and Practices
Hey everyone!
Writing Go code that works is one thing. Writing idiomatic Go code, that follows the languageâs principles and conventions, is completely different.
Idiomatic code is more readable, easier to maintain, more efficient, and easier to review. Itâs the kind of code that other Go developers immediately recognize as âgood Go code.â
In this post, weâll see practical examples of how to transform non-idiomatic code into code that follows Go principles.
What is idiomatic code?
Idiomatic Go code follows:
- Simplicity: clear and direct code, without unnecessary complexity
- Composition: small functions that combine to solve larger problems
- Small interfaces: interfaces with few methods, focused on one responsibility
- Explicit error handling: errors are values, not exceptions
- Community conventions: names, structure, and patterns accepted by the Go community
1. Error handling: explicit and clear
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func processUser(id int) {
user, err := getUser(id)
if err != nil {
log.Println(err)
return
}
result, err := validateUser(user)
if err != nil {
log.Println(err)
return
}
saveUser(result)
}
Problems:
- Errors are only logged, without context
- No way to distinguish error types
- Calling code doesnât know what happened
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func processUser(id int) error {
user, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %d: %w", id, err)
}
result, err := validateUser(user)
if err != nil {
return fmt.Errorf("validate user: %w", err)
}
if err := saveUser(result); err != nil {
return fmt.Errorf("save user: %w", err)
}
return nil
}
Improvements:
- Errors are propagated with context using
%w(error wrapping) - Function returns error, allowing proper handling in caller
- Clear context about where the error occurred
2. Clear and consistent names
â Not idiomatic
1
2
3
4
5
6
7
8
9
func proc(d []byte) ([]byte, error) {
var r []byte
for i := 0; i < len(d); i++ {
if d[i] != 0 {
r = append(r, d[i])
}
}
return r, nil
}
Problems:
- Abbreviated names (
proc,d,r) are not clear - Doesnât follow Go conventions (names should be descriptive)
â Idiomatic
1
2
3
4
5
6
7
8
9
func removeNullBytes(data []byte) ([]byte, error) {
var result []byte
for _, b := range data {
if b != 0 {
result = append(result, b)
}
}
return result, nil
}
Improvements:
- Descriptive and clear names
- Uses
rangeinstead of manual indexing - Function name describes what it does
4. Use defer for cleanup
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func processFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
data, err := ioutil.ReadAll(file)
if err != nil {
file.Close() // Can be forgotten in other paths
return err
}
// process data...
file.Close()
return nil
}
Problems:
- Easy to forget
Close()in some error path - Duplicated code
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func processFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close() // Always executed, even on error
data, err := ioutil.ReadAll(file)
if err != nil {
return err
}
// process data...
return nil
}
Improvements:
deferensures cleanup even on error or panic- Cleaner and safer code
5. Avoid unnecessary variables
â Not idiomatic
1
2
3
4
5
6
7
8
9
func getUserName(id int) string {
user, err := getUser(id)
if err != nil {
return ""
}
name := user.Name
return name
}
Problems:
- Unnecessary intermediate variable
â Idiomatic
1
2
3
4
5
6
7
func getUserName(id int) string {
user, err := getUser(id)
if err != nil {
return ""
}
return user.Name
}
Or, if validation is needed:
1
2
3
4
5
6
7
func getUserName(id int) (string, error) {
user, err := getUser(id)
if err != nil {
return "", err
}
return user.Name, nil
}
Improvements:
- More direct code
- Returns error when appropriate
6. Use context for cancellation and timeouts
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
func fetchUserData(id int) (*User, error) {
// No timeout, can hang indefinitely
resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
if err != nil {
return nil, err
}
defer resp.Body.Close()
// process response...
return user, nil
}
Problems:
- No timeout control
- Cannot be cancelled
- Can hang the goroutine
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func fetchUserData(ctx context.Context, id int) (*User, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://api.example.com/users/%d", id), nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if context was cancelled
if err := ctx.Err(); err != nil {
return nil, err
}
// process response...
return user, nil
}
// Usage with timeout
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
user, err := fetchUserData(ctx, 123)
if err != nil {
log.Fatal(err)
}
}
Improvements:
- Timeout and cancellation control
- Prevents stuck goroutines
- Go standard for async operations
7. Prefer composition over inheritance
â Not idiomatic (trying to mimic OOP)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Animal struct {
Name string
}
func (a *Animal) Speak() string {
return "Some sound"
}
type Dog struct {
Animal // "Inheritance"
}
func (d *Dog) Speak() string {
return "Woof"
}
Problems:
- Go doesnât have inheritance, only composition
- Embedding can be confusing if misused
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Speaker interface {
Speak() string
}
type Animal struct {
Name string
}
type Dog struct {
Animal
}
func (d *Dog) Speak() string {
return "Woof"
}
// Usage
func makeSound(s Speaker) {
fmt.Println(s.Speak())
}
Improvements:
- Uses interfaces for polymorphism
- Clear and explicit composition
- More flexible and testable
8. Avoid panic in normal code
â Not idiomatic
1
2
3
4
5
6
func divide(a, b int) int {
if b == 0 {
panic("division by zero")
}
return a / b
}
Problems:
panicshould only be used for programming errors- Calling code cannot handle the error
â Idiomatic
1
2
3
4
5
6
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero: %d / %d", a, b)
}
return a / b, nil
}
Improvements:
- Error returned as value
- Caller can decide how to handle
- Safer and more predictable
9. Use make and len appropriately
â Not idiomatic
1
2
3
4
5
6
7
8
9
func processItems(items []Item) {
result := []Item{} // Empty slice, but can cause reallocations
for i := 0; i < len(items); i++ {
if items[i].IsValid() {
result = append(result, items[i])
}
}
}
Problems:
- Empty slice can cause multiple reallocations
- Manual loop with index
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
func processItems(items []Item) []Item {
// Pre-allocate with estimated capacity
result := make([]Item, 0, len(items))
for _, item := range items {
if item.IsValid() {
result = append(result, item)
}
}
return result
}
Improvements:
makewith capacity prevents reallocationsrangeis more idiomatic and safer- Better performance
10. Error handling: sentinel errors
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func getUser(id int) (*User, error) {
if id < 0 {
return nil, fmt.Errorf("invalid id")
}
// ...
}
// In caller
user, err := getUser(-1)
if err != nil {
if strings.Contains(err.Error(), "invalid") {
// Fragile handling
}
}
Problems:
- String comparison is fragile
- No way to check error type safely
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var ErrInvalidID = errors.New("invalid user id")
var ErrUserNotFound = errors.New("user not found")
func getUser(id int) (*User, error) {
if id < 0 {
return nil, ErrInvalidID
}
// ...
return nil, ErrUserNotFound
}
// In caller
user, err := getUser(-1)
if err != nil {
if errors.Is(err, ErrInvalidID) {
// Specific handling
}
}
Improvements:
- Sentinel errors allow safe comparison
errors.Isworks with error wrapping- More robust and testable
11. Use type assertions safely
â Not idiomatic
1
2
3
4
func processValue(v interface{}) {
str := v.(string) // Panic if not string!
fmt.Println(str)
}
Problems:
- Type assertion can cause panic
- Doesnât check type first
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func processValue(v interface{}) {
str, ok := v.(string)
if !ok {
return // or return error
}
fmt.Println(str)
}
// Or with type switch
func processValue(v interface{}) {
switch val := v.(type) {
case string:
fmt.Println(val)
case int:
fmt.Printf("%d\n", val)
default:
fmt.Printf("unknown type: %T\n", val)
}
}
Improvements:
- Safe check with
ok - Type switch for multiple types
- No panic risk
13. Avoid global variables
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", "...")
if err != nil {
log.Fatal(err)
}
}
func getUser(id int) (*User, error) {
// Uses global db
row := db.QueryRow("SELECT ...", id)
// ...
}
Problems:
- Hard to test (canât inject mock)
- Global state is hard to manage
- Implicit dependencies
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetUser(id int) (*User, error) {
row := r.db.QueryRow("SELECT ...", id)
// ...
}
// Usage
func main() {
db, _ := sql.Open("postgres", "...")
repo := NewUserRepository(db)
user, _ := repo.GetUser(123)
}
Improvements:
- Explicit dependencies via constructor
- Easy to test (can inject mock)
- No global state
14. Use sync package appropriately
â Not idiomatic
1
2
3
4
5
6
7
8
9
10
11
type Counter struct {
count int
}
func (c *Counter) Increment() {
c.count++ // Race condition!
}
func (c *Counter) Get() int {
return c.count // Race condition!
}
Problems:
- Race conditions in concurrent code
- Not thread-safe
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Counter struct {
mu sync.RWMutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Get() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.count
}
// Or, for simple counters
type Counter struct {
count int64
}
func (c *Counter) Increment() {
atomic.AddInt64(&c.count, 1)
}
func (c *Counter) Get() int64 {
return atomic.LoadInt64(&c.count)
}
Improvements:
- Thread-safe with mutex or atomic
deferensures unlock even on panic- RWMutex to optimize concurrent reads
15. Documentation: useful comments
â Not idiomatic
1
2
3
4
5
6
7
8
9
// getUser gets a user
func getUser(id int) (*User, error) {
// ...
}
// Process processes data
func process(data []byte) {
// ...
}
Problems:
- Obvious comments that donât add value
- Donât follow Go convention (should start with function name)
â Idiomatic
1
2
3
4
5
6
7
8
9
10
11
// getUser retrieves a user by ID from the database.
// It returns ErrUserNotFound if the user doesn't exist.
func getUser(id int) (*User, error) {
// ...
}
// process validates and normalizes user data before storage.
// It removes null bytes and trims whitespace.
func process(data []byte) ([]byte, error) {
// ...
}
Improvements:
- Comments explain the âwhyâ, not the âwhatâ
- Follow Go convention (start with function name)
- Document behavior and special cases
Idiomatic code checklist
When reviewing your Go code, check:
- Errors are returned and propagated with context (
%w) - Names are descriptive and clear
deferis used for resource cleanupcontextis used for cancellation and timeouts- Composition is preferred over âinheritanceâ
panicis avoided in normal codemakeis used with capacity when known- Sentinel errors are used for expected errors
- Type assertions are checked with
ok - Global variables are avoided
- Concurrent code uses
syncappropriately - Comments explain the âwhyâ, not the âwhatâ
Conclusion
Idiomatic Go code isnât about blindly following rules, but understanding the languageâs principles:
- Simplicity: clear and direct code
- Composition: small pieces that combine
- Explicitness: explicit errors, explicit dependencies
- Safety: proper handling of concurrency and resources
Writing idiomatic code makes your code more readable, easier to maintain, and more aligned with the Go communityâs expectations. Itâs the kind of code that other Go developers recognize and appreciate.
