How Go Code Is Organized

Updated

September 13, 2026

How Go Code Is Organized

A module is the unit you version and download. A package is the unit you import and compile. The boring default is one module, one package main at the root, until a real boundary appears. Capitals are the public API.

Mental model

go.mod names the module (module desk or module example.com/desk). Every import path starts with that module path, then the directory: example.com/desk/greet.

A directory is one package. The package clause is the name importers use in code (greet.Hello), not the directory name — though they should match.

package main plus func main is a program. Any other package name is a library. Names that start with a capital letter are exported (visible to other packages). The rest are private to the package.

internal/ is enforced by the toolchain: only code rooted at the parent of internal may import it. cmd/ is a convention, not a rule: one directory per binary when you have more than one, or when the root is a library.

Worked examples

Case 1: Exported means a capital letter

Save as names.go. Both functions live in package main, so both are callable here. The names still teach the rule you will need the moment a second package exists.

// names.go
package main

import "fmt"

func OpenShift(name string) string {
    return name + " is on"
}

func closeShift(name string) string {
    return name + " is off"
}

func main() {
    fmt.Println(OpenShift("Amina"))
    fmt.Println(closeShift("Amina"))
}

Run:

go run names.go

Output:

Amina is on
Amina is off

Another package could call OpenShift. It could not call closeShift. There is no public keyword. The first letter is the whole policy.

Case 2: A tiny two-package module

Three files, one module. Create a directory, then save each listing at the path in the comment.

go.mod:

module example.com/desk

go 1.27

Save as greet/greet.go:

// greet.go
package greet

import "fmt"

func Hello(name string) string {
    return fmt.Sprintf("hello, %s", name)
}

func whisper(name string) string {
    return "(" + name + ")"
}

Save as main.go:

// main.go
package main

import (
    "fmt"

    "example.com/desk/greet"
)

func main() {
    fmt.Println(greet.Hello("desk"))
}

Run from the directory that contains go.mod:

go run .

Output:

hello, desk

The import path is example.com/desk plus /greet. The identifier is greet.Hello. greet.whisper would not compile from main.go — try it once and read the error.

whisper is not dead code. Other files in package greet could call it. Privacy is per package, not per file.

Case 3: cmd/ and internal/

Same module path. Layout:

.
├── go.mod
├── cmd/desk/main.go
└── internal/ticket/ticket.go

go.mod is unchanged:

module example.com/desk

go 1.27

Save as internal/ticket/ticket.go:

// ticket.go
package ticket

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

func (t Ticket) Label() string {
    return fmt.Sprintf("ticket %d → table %d", t.ID, t.Table)
}

Save as cmd/desk/main.go:

// main.go
package main

import (
    "fmt"

    "example.com/desk/internal/ticket"
)

func main() {
    t := ticket.Ticket{ID: 9, Table: 2}
    fmt.Println(t.Label())
}

Run:

go run ./cmd/desk

Output:

ticket 9 → table 2

example.com/desk/internal/ticket is importable from example.com/desk/.... A different module cannot import it. That is the whole point of internal/.

You do not need cmd/ for a single binary. Root package main (Case 2) is enough. Add cmd/desk when the repository also holds a library, or a second command.

The trap

Splitting a 60-line desk tool into pkg/, internal/, cmd/, and four packages named util, helpers, common, and types. Import paths get longer. Names get shorter. Nobody can find OpenShift.

This complete program is the whole application. It does not need a directory tree.

// one_package.go
package main

import "fmt"

type Shift struct {
    Name string
    On   bool
}

func (s Shift) Line() string {
    state := "off"
    if s.On {
        state = "on"
    }
    return s.Name + " is " + state
}

func main() {
    fmt.Println(Shift{Name: "Bo", On: true}.Line())
}

Run:

go run one_package.go

Output:

Bo is on

The other trap is the mirror image: exporting every name “so tests can see it,” or putting two packages in one directory (package greet next to package main). One directory, one package. Unexported tests use package greet in greet_test.go; that is a later chapter.

The boring rule

  • One module per project until you publish two versioned units.
  • Import path = module path + directory.
  • Capital letter = API. Lowercase = package private.
  • Start with package main at the root. Add greet/ when a name is reused.
  • Add internal/ when you need a wall against other modules. Add cmd/<name> when you have two binaries or a library plus a binary.
  • Do not invent pkg/ or utils/ on day one.

Try this

  1. In Case 2, change main.go to call greet.whisper("desk"). Read the compiler error. Change whisper to Whisper and fix the call.
  2. In Case 3, add cmd/board/main.go that also imports internal/ticket and prints a different ticket. Run go run ./cmd/board.
  3. Rename the module path in go.mod to example.com/cafe and update every import. Run go run . (or go run ./cmd/desk) until it builds.