Call
Backend Basics

Go for Beginners: Packages, Structs, Interfaces, Errors, Goroutines, Channels, Generics, and Modules

Learn Go from first principles: modules and packages, structs, methods, interfaces, errors, defer, slices/maps, goroutines, channels, select, context cancellation, generics, workspaces, testing, fuzzing, race detection, and Go 1.27 basics.

Published: May 23, 2025
Updated: September 12, 2026
10 min read
Go for Beginners: Packages, Structs, Interfaces, Errors, Goroutines, Channels, Generics, and Modules

Go for Beginners: Packages, Structs, Interfaces, Errors, Goroutines, Channels, Generics, and Modules

Go is a strongly typed, garbage-collected programming language designed for simple tooling, fast builds, networked software, and explicit concurrency.

It has a deliberately small language compared with many older enterprise languages. The goal is not to give developers ten ways to express the same idea; it is to make code easy to read across teams while putting powerful concurrency and runtime features in the standard toolchain.

As of September 2026, Go 1.27 is the current language release family. Go 1.27 added generic methods while continuing the Go 1 compatibility promise.

Start with a module

A Go project normally lives inside a module.

Create one with:

mkdir hello
cd hello
go mod init example.com/hello

This creates go.mod, which records the module path, Go version information, and dependency requirements.

A small program in main.go can be:

package main

import "fmt"

func main() {
    fmt.Println("Hello, Go")
}

Run it with:

go run .

Build a binary with:

go build .

Packages organize Go code

Every .go file belongs to a package.

A module can contain many packages:

example.com/shop
  cmd/api
  internal/orders
  internal/payments
  internal/platform

Package names should represent a useful capability rather than generic buckets such as utils containing unrelated code.

Keep package APIs small. Unexported identifiers start with a lowercase letter; exported identifiers start with an uppercase letter.

Variables are statically typed

You can declare a variable explicitly:

var port int = 8080

or let the compiler infer the type:

port := 8080

The short declaration syntax is available inside functions.

Go is statically typed: port is still an int; it does not become dynamically typed because the type was inferred.

Go has simple built-in types

Common types include:

bool
string
int, int8, int16, int32, int64
uint variants
float32, float64
complex64, complex128
byte
rune

byte is an alias for uint8.

rune is an alias for int32 and commonly represents a Unicode code point.

Remember that Go strings contain bytes, generally UTF-8 text by convention. Indexing a string returns a byte, not necessarily one whole human-visible character.

Arrays and slices are different

An array has a fixed length that is part of its type:

var values [3]int

Slices are the dynamic sequence abstraction used much more often:

values := []int{10, 20, 30}
values = append(values, 40)

A slice is a descriptor over an underlying array.

That means two slices can refer to overlapping storage. Mutating one can affect the other when they share the same backing array.

This is an important ownership detail when returning or retaining slices across API boundaries.

Maps store key/value data

Create a map with:

prices := map[string]int{
    "keyboard": 4999,
    "mouse": 1999,
}

Read with the comma-ok form when absence matters:

price, ok := prices["keyboard"]
if !ok {
    // key not present
}

A missing key returns the value type's zero value, so the ok result distinguishes “missing” from “present with zero value.”

Normal maps are not safe for unsynchronized concurrent writes.

Structs model application data

A struct groups named fields:

type Product struct {
    ID         string
    Name       string
    PriceMinor int64
}

Create one with:

product := Product{
    ID:         "p42",
    Name:       "Keyboard",
    PriceMinor: 499900,
}

Structs are commonly used for:

  • domain entities
  • request/response DTOs
  • configuration
  • database rows

Prefer meaningful domain types over passing unrelated maps of string to any throughout the application.

Methods attach behavior to types

A method has a receiver:

type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++
}

Use a pointer receiver when the method should mutate the value or when copying the receiver would be undesirable.

A value receiver works well for small immutable-style values where method execution should operate on a copy.

Interfaces describe behavior implicitly

Go interfaces are satisfied implicitly.

type Clock interface {
    Now() time.Time
}

A type does not need an implements Clock declaration. If it has the required method set, it satisfies the interface.

This encourages small capability-oriented interfaces.

A strong Go rule is to define interfaces where they are consumed, not automatically beside every implementation.

Avoid huge interfaces that force test doubles and implementations to support methods a caller never needs.

Errors are ordinary values

Go commonly returns an error as a second result:

func LoadProduct(id string) (Product, error) {
    // ...
}

Callers handle it explicitly:

product, err := LoadProduct(id)
if err != nil {
    return err
}

This makes failure visible in normal control flow.

Wrap errors with context:

return fmt.Errorf("load product %s: %w", id, err)

Then use errors.Is and errors.As for stable programmatic inspection rather than comparing error strings.

defer schedules cleanup

defer runs a function when the surrounding function returns.

Typical resource cleanup:

file, err := os.Open(path)
if err != nil {
    return err
}
defer file.Close()

defer is useful for:

  • closing files
  • unlocking mutexes
  • cancelling contexts
  • ending traces

Be aware that deferred calls execute in last-in-first-out order.

Panic is not normal error handling

A panic is appropriate for an unrecoverable programming/runtime condition, not an expected user error.

For APIs, ordinary failures such as invalid input, not-found resources, or database errors should flow through error values.

Servers often recover panics at the outer request boundary to avoid one bug crashing the whole process, but the panic should still be logged and fixed.

Goroutines make concurrent work simple

Start concurrent execution with go:

go process(job)

A goroutine is managed by the Go runtime rather than being one dedicated OS thread.

This makes goroutines especially useful for large numbers of I/O-bound tasks.

But goroutines are not free and should not be created without lifecycle limits.

Every long-running goroutine should have a clear way to stop.

Channels communicate between goroutines

A channel carries typed values:

jobs := make(chan Job)

Send:

jobs <- job

Receive:

job := <-jobs

Buffered channels can hold a bounded number of values:

jobs := make(chan Job, 100)

A channel is synchronization and communication inside a process. It is not a durable job queue: if the process crashes, in-memory messages disappear.

select waits on multiple channel operations

select is useful for work plus cancellation:

select {
case job := <-jobs:
    handle(job)
case <-ctx.Done():
    return
}

This pattern is common in workers and long-running goroutines.

Use contexts for cancellation/deadline propagation rather than inventing a different cancellation channel in every library.

Context carries cancellation and deadlines

Many standard-library and database/network APIs accept context.Context.

For an HTTP request:

ctx := r.Context()

Pass it through the request path so database and remote calls can stop when the caller disconnects or the deadline expires.

Contexts should normally be passed explicitly as the first parameter rather than stored permanently inside application structs.

Do not use context values as a general-purpose dependency container.

Mutexes are fine when shared state is simpler

Go encourages communication through channels, but channels are not mandatory for all concurrency.

A mutex can be the clearest tool for protecting a small shared structure:

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

The right question is not “channels or mutexes?” It is which mechanism makes ownership and synchronization easiest to prove correct.

Generics reduce repeated type-specific code

Go supports generic types and functions.

Conceptually, one generic function can work across a constrained set of types instead of duplicating the algorithm for every type.

Go 1.27 also added generic methods, expanding where type parameters can be declared.

Use generics when they solve real type duplication.

Go's style still favors understandable concrete code over abstraction for its own sake.

The zero value is intentionally useful

Many Go types are designed so their zero value works immediately.

Examples include:

0 for numbers
false for bool
"" for string
nil for pointers/slices/maps/channels/functions/interfaces

Standard types such as sync.Mutex can be used safely from their zero value.

Designing your own types with useful zero values can reduce constructors and configuration boilerplate.

But do not rely on zero values when “unset” and a real zero have different business meanings. Use pointers/options/status fields intentionally when absence must be represented.

Modules manage dependencies

go.mod records module requirements.

Common commands include:

go get example.com/package
go mod tidy
go list -m all

Go's module system also uses checksums to verify downloaded module content through go.sum and the checksum database model where configured.

Commit go.mod and go.sum for normal applications.

Review dependency updates like source-code changes.

Workspaces help develop multiple modules together

A go.work file can combine several local modules during development:

go work init ./service ./sharedlib

This is useful when changing a library and its consumer together without publishing an intermediate module version.

Do not commit a workspace file blindly if your repository/development model does not intend all contributors to use the same local module layout.

Testing is built into the toolchain

Tests live in files ending in _test.go:

func TestTotal(t *testing.T) {
    got := Total(2, 100)
    if got != 200 {
        t.Fatalf("got %d", got)
    }
}

Run:

go test ./...

Go also includes:

  • benchmarks
  • examples
  • fuzz testing
  • coverage tooling
  • race detection

This integrated tooling is a major reason Go projects can keep relatively consistent build/test workflows.

Fuzzing is part of standard Go testing

Go's fuzzing tools generate inputs to discover edge cases and crash/security bugs.

Fuzz parsers and untrusted-input boundaries such as:

  • custom protocols
  • file formats
  • URL/token parsers
  • serialization

A fuzz test complements unit tests; it does not replace expected-case assertions.

Use the race detector for concurrent code

Run:

go test -race ./...

The race detector detects data races in code paths exercised during the run.

A clean run does not prove unexecuted paths are race-free, so combine it with realistic concurrency tests.

Formatting is standardized

Use:

gofmt -w .

or tooling that invokes the Go formatter.

Go's standard formatting removes an entire category of team style debates.

go vet and additional analyzers can catch suspicious constructs beyond compiler errors.

Go's compatibility promise is important

Go 1 releases follow the Go 1 compatibility promise: programs written for earlier Go 1 versions should generally continue to compile and run correctly on newer Go 1 versions, with documented exceptions/clarifications.

This allows the language, compiler, runtime, and standard library to improve while keeping upgrade cost relatively low.

Still test every runtime/toolchain upgrade against your application and dependencies.

Where Go fits especially well

Go is commonly a strong fit for:

  • APIs and microservices
  • network servers/proxies
  • infrastructure agents
  • CLIs
  • message consumers
  • cloud/platform tooling
  • observability services

Its strengths are most visible where simple concurrency, predictable deployment, and maintainable backend code matter.

Where another language may fit better

Another ecosystem can be a better choice when:

  • heavy scientific/numerical libraries dominate
  • browser/frontend code should share one language/runtime
  • extremely low-level manual memory/layout control is central
  • a mature domain-specific platform exists elsewhere

Language choice should include team and ecosystem economics, not only syntax or benchmarks.

Beginner checklist

Before moving to advanced Go, make sure you understand:

  1. modules and packages
  2. exported vs unexported identifiers
  3. slices and maps
  4. structs and methods
  5. small implicit interfaces
  6. explicit error values and wrapping
  7. defer
  8. goroutines
  9. channels and select
  10. contexts and cancellation
  11. mutexes/shared state
  12. generics
  13. tests, fuzzing, race detection, and formatting

Go becomes much easier once you stop looking for heavy framework patterns and lean into its central design: small packages, explicit data and errors, simple interfaces, and concurrency that remains understandable in ordinary code.

Official references

Tags:GoGolangGo BasicsProgramming LanguagesBackend DevelopmentConcurrency
ENTERPRISE SAAS ENGINEERING

BUILDING A SAAS PRODUCT?

Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.

EXPLORE SAAS SERVICES →