How to Organize a Go Project

Standard Project Layout

Use cmd for apps, internal for private code, and pkg for public libraries in your Go project.

The single file trap

You start with a single main.go. It prints "Hello" and exits. You add a database connection. You add an HTTP handler. You add a config parser. Suddenly main.go is 800 lines long. You refactor by splitting files, but now you have utils.go, helpers.go, lib.go, and nothing tells you what belongs where. A friend tries to import your project and accidentally calls a function that should be private. The compiler lets them. You need structure that enforces boundaries, not just folders.

Go does not force a directory structure. The language compiler does not care if your code lives in src/, lib/, or random_folder/. The structure comes from convention and tooling. The standard layout is a shared mental model. It tells other Go developers exactly where to look. It also uses the internal directory to create hard boundaries that the compiler enforces.

Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. The compiler uses capitalization to decide visibility. This rule applies to packages, types, functions, and fields. Structure your directories to match this visibility model.

Go gives you freedom. The standard layout gives you clarity.

The module anchor

Every Go project starts with a module. The go.mod file at the root declares the module path and the minimum Go version. The module path becomes the import prefix for every package inside the project. If your module path is example.com/myproject, then the package in internal/config is imported as example.com/myproject/internal/config.

The module path matters even for local development. It determines how the toolchain resolves imports. Pick a path that is unique and stable. Use a domain you control, or a GitHub path like github.com/user/repo. Changing the module path later requires rewriting every import in the project and updating all dependencies.

The module path is your project's identity. Pick it once and stick with it.

Standard layout skeleton

Here's the skeleton of a standard Go project.

myproject/
  go.mod          # defines the module path and Go version
  cmd/
    myapp/
      main.go     # entry point for the executable
  internal/
    config/
      config.go   # private code, cannot be imported by outsiders
  pkg/
    utils/
      utils.go    # public library code, safe for external use

The cmd/ directory holds executables. Each subdirectory under cmd/ contains a main package. This keeps multiple binaries in one repository clean. If you have a CLI tool and a web server, they live in cmd/cli and cmd/server. They share code via internal/ or pkg/.

Go builds packages, not directories. If you put main.go directly in cmd/, that directory becomes a package. You cannot have two main packages in one directory. Subdirectories solve this by creating separate packages.

The internal/ directory is private. Any directory named internal creates a boundary. Code inside internal/ cannot be imported by packages outside the module. This rule is recursive. internal/pkg is private to the module. pkg/internal is private to pkg. This allows fine-grained control over visibility.

The pkg/ directory is optional. It holds public library code intended for other projects to import. Many application-only projects omit pkg/ and rely solely on internal/. Using pkg/ signals that the code is part of a stable public API.

Trust gofmt. Argue logic, not formatting. Most editors run gofmt on save. The tool decides indentation and layout. The community follows this convention to avoid style debates.

Enforcing boundaries with internal

Here's how the internal package enforces privacy.

package config

// Load reads configuration from a file and returns the parsed struct.
func Load(path string) (*Config, error) {
    // implementation details omitted for brevity
    return &Config{}, nil
}

// Config holds application settings.
type Config struct {
    Port  int
    Debug bool
}

The receiver name is usually one or two letters matching the type. (c *Config) is standard. (this *Config) or (self *Config) are not. This convention keeps method signatures short and readable.

The main package imports internal code without issue.

package main

import (
    "fmt"

    "example.com/myproject/internal/config"
)

func main() {
    cfg, err := config.Load("config.yaml")
    if err != nil {
        fmt.Println("failed to load config:", err)
        return
    }
    // use cfg
}

The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Errors are values, not exceptions. You handle them where they occur.

An external project cannot import the internal package.

package main

import (
    // this import fails at compile time
    "example.com/myproject/internal/config"
)

func main() {
    _ = config.Load
}

The compiler rejects this with package example.com/myproject/internal/config is not in std. The error message varies slightly by version, but the result is the same: the import is forbidden. The underscore discards the value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Dropping an error without checking it hides bugs.

The compiler guards your secrets. Trust the internal directory.

Pitfalls and anti-patterns

The biggest pitfall is putting everything in pkg/. If you expose a function in pkg/, it becomes part of your public API. Changing it later breaks users. Use internal/ for anything that shouldn't be touched by outsiders. If you are not sure whether code should be public, make it internal. You can always move it to pkg/ later. Moving code from pkg/ to internal/ is a breaking change.

Another pitfall is circular dependencies. Go forbids import cycles. If package A imports B and B imports A, the compiler stops you. Structure your code to flow downward. High-level code in cmd/ imports business logic. Business logic imports data access. Data access never imports business logic.

The compiler rejects cycles with import cycle not allowed. This error saves you from runtime deadlocks and tangled dependencies. Resolve cycles by extracting shared interfaces or moving code to a lower layer.

Accept interfaces, return structs. This mantra guides API design. Functions should accept interfaces to allow flexibility. They should return structs to keep the implementation concrete. This pattern keeps dependencies loose and tests easy.

Don't pass a *string. Strings are already cheap to pass by value. A string header is two words: a pointer and a length. Passing a pointer to a string adds indirection without saving memory. Use string for parameters. Use *string only when you need to represent a nullable value or modify the string in place.

Context is plumbing. Run it through every long-lived call site. Functions that perform I/O or spawn goroutines should accept a context.Context as the first parameter, conventionally named ctx. Respect cancellation and deadlines. The worst goroutine bug is the one that never logs. Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path.

Structure prevents accidents. Import cycles are impossible. Leaky abstractions are a choice.

When to use what

Use cmd/ when you need an executable entry point. Each binary gets its own subdirectory.

Use internal/ when code must remain private to the module. The compiler enforces this boundary.

Use pkg/ when you are building a library intended for other projects to import.

Use the root directory for top-level configuration files like go.mod, LICENSE, and README.md.

Use web/ or frontend/ when your project includes static assets or a separate frontend build.

Skip pkg/ entirely if your project is a single application with no public library surface. Many Go projects omit pkg/ and rely solely on internal/.

Start simple. Add structure when the compiler or your team demands it.

Where to go next