How to Organize Code in a Go Project

Organize Go projects by creating a module with a go.mod file and separating executables in cmd from libraries in subdirectories.

The folder trap

You create a directory. You write a file. You try to import that file from another file. The compiler rejects the program. You move the file. You rename the folder. You try again. The error changes, but the program still won't build.

This happens because Go does not organize code by folder structure alone. Go organizes code by import paths. The filesystem is just a map. The go.mod file is the anchor that tells the compiler where the map starts. If you treat a Go project like a Python package or a JavaScript module, you will fight the toolchain. Go expects a module, not just a collection of scripts.

The moment you run go mod init, you define a module path. Every import in your project must resolve relative to that path. The directory names matter, but only because they become part of the import address. The compiler does not care if you put your code in src, lib, or code. It cares that the import string matches the directory tree rooted at go.mod.

Modules and import paths

A Go module is a collection of packages that are released together. The go.mod file declares the module path and the Go version. The module path acts like a domain name for your code. It is usually a URL, even if the code lives on your laptop.

The import path is the address of a package. When you write import "example.com/myproject/handlers", the compiler takes the module path from go.mod, strips it from the import string, and looks for a directory with the remaining name.

Think of go.mod as the front door of a house. The import path is the full address. If you tell someone to go to "Kitchen", they do not know which house you mean. You need "123 Main St, Kitchen". In Go, go.mod defines "123 Main St". The import path provides the rest. The compiler walks the directory tree from the module root to find the package.

This design keeps imports explicit. There is no implicit search path. There is no PYTHONPATH or NODE_PATH magic. If the import path does not match the directory structure relative to go.mod, the compiler stops. This eliminates ambiguity. You always know exactly where a package comes from.

Minimal example

Here is the smallest possible Go project. It has one module, one package, and one file. The module path is example.com/myproject. The file lives in the root directory.

// go.mod
// Declares the module path. This string becomes the prefix for all imports.
module example.com/myproject

// Requires Go 1.21 or later. The toolchain enforces this version.
go 1.21
// main.go
// Package main declares an executable program. Only the main package can have a main function.
package main

import "fmt"

// main is the entry point. The compiler links this function to create the binary.
func main() {
    // Prints to stdout. The fmt package is part of the standard library.
    fmt.Println("Hello from myproject")
}

This works because main.go is in the same directory as go.mod. The package name is main. The import path for this package is example.com/myproject. If you move main.go into a subdirectory called app, the import path becomes example.com/myproject/app. The package name inside the file can still be main, but the address changes.

The compiler resolves imports by matching the directory tree to the module path. It does not look at the package name declared in the file. The package name is used for the receiver and for local references within the file. The import path is the global identity.

How the compiler resolves imports

When the compiler sees an import, it performs a lookup. First, it checks the standard library. If the import starts with a known standard package name like fmt or net/http, it finds it in the Go installation.

If the import is not standard, the compiler looks at the module graph. It reads go.mod to find the module path. It compares the import string to the module path. If the import starts with the module path, the compiler treats it as a local package. It strips the module path and treats the remainder as a directory path relative to the go.mod file.

For example, if go.mod declares module example.com/myproject, and you import example.com/myproject/internal/db, the compiler looks for a directory named internal containing a subdirectory named db. It finds all .go files in that directory and compiles them as one package.

This resolution happens at compile time. The compiler does not search the filesystem blindly. It follows the module graph defined in go.mod and go.sum. If a directory does not exist, or if the import path does not match any known module, the compiler rejects the program.

The go.sum file stores cryptographic hashes of dependencies. It ensures that the code you build is exactly the code the module author published. You do not edit go.sum manually. The go tool updates it when you add or update dependencies.

Realistic project structure

Real projects grow. You need multiple binaries. You need shared logic. You need to hide implementation details. The community has converged on a few conventions. These are not enforced by the compiler, but they make code easier to navigate.

Here is a common layout for a service with a CLI tool and a web API.

// go.mod
// Module path matches the repository URL. This makes imports stable across clones.
module example.com/myproject

go 1.21
// cmd/server/main.go
// cmd directories hold executable entry points. Each binary gets its own directory.
package main

import (
    // Imports the internal package. The compiler allows this because cmd is a child of the module root.
    "example.com/myproject/internal/config"
    
    // Imports the public package. This package can be imported by external projects.
    "example.com/myproject/pkg/api"
)

// main starts the server. It reads config and launches the HTTP handler.
func main() {
    // Loads configuration from environment variables or files.
    cfg := config.Load()
    
    // Starts the API server on the configured port.
    api.Start(cfg)
}
// internal/config/config.go
// internal directories are private. The compiler blocks imports from outside the parent directory.
package config

// Config holds application settings. It is exported because the name starts with a capital letter.
type Config struct {
    Port int
}

// Load reads configuration and returns a populated struct.
func Load() Config {
    // Returns a default config for simplicity. Real code reads env vars or files.
    return Config{Port: 8080}
}
// pkg/api/server.go
// pkg directories hold public library code. External projects can import these packages.
package api

import "fmt"

// Start prints a message. In a real app, this would create an HTTP server.
func Start(port int) {
    // Logs the port. The fmt package handles string formatting.
    fmt.Printf("Starting server on port %d\n", port)
}

This structure separates concerns. cmd contains only entry points. internal contains code that must not be imported by other modules. pkg contains reusable logic that other modules might use.

The internal directory is special. The Go compiler enforces privacy based on directory names. If a directory is named internal, no package outside its parent can import it. This is structural privacy. You do not need to rename functions or add comments. The compiler checks the path. If you try to import example.com/myproject/internal/config from a different module, the compiler rejects the program with import "example.com/myproject/internal/config" not allowed: internal package.

This feature is powerful. It lets you share code within a module while hiding implementation details from the world. You can refactor internal code without breaking external users.

Pitfalls and compiler errors

Project organization trips up beginners in predictable ways. The compiler errors are verbose, but they point directly to the problem.

Import path mismatch. You create a directory lib and put code in it. You try to import example.com/myproject/lib. The compiler says import "example.com/myproject/lib" not found. This happens when the directory does not exist relative to go.mod, or when the module path in go.mod does not match the import prefix. Check that go.mod declares the correct module path. Check that the directory structure matches the import string.

Package name confusion. You name a directory utils. You name the package inside util. You import it as utils. The compiler complains with import "example.com/myproject/utils" is a program, not an importable package if the package is main, or it works but the alias is confusing. The package name inside the file does not affect the import path. The import path is determined by the directory. The package name is used for the receiver and for local references. Keep them consistent to avoid confusion. Name the directory and the package the same way. Use lowercase, singular names. utils is a directory. utils is the package. Not Utils or my_utils.

Internal access denied. You put code in internal. You try to import it from a test file in a different module. The compiler rejects the program with import "..." not allowed: internal package. This is by design. Move the code to pkg if it needs to be public. Or use go test within the same module to test internal code.

Missing go.mod. You clone a repository. You run go run main.go. The compiler says go: cannot find main module, but found .git/config in .... This happens when the project does not have a go.mod file. Run go mod init to create one. The module path should match the repository URL.

Vendor directory confusion. You see a vendor directory. You wonder if you should use it. The vendor directory contains local copies of dependencies. It is optional. The go tool manages dependencies via the module proxy by default. Use vendor only if you need offline builds or strict control over dependency versions. Do not commit vendor to version control unless your project requires it.

Decision matrix

Go does not force a layout. You can put files anywhere. The compiler only cares about import paths. Use these conventions to keep projects maintainable.

Use cmd when you have an executable binary. Put each binary in its own subdirectory under cmd. Name the directory after the binary. The main package should only live in cmd. This keeps entry points separate from library code.

Use internal when you have code that must not be imported by other modules. Put private packages in internal. The compiler enforces this boundary. Use internal for configuration, database drivers, or business logic that is specific to this project.

Use pkg when you have public library code that other projects might import. Put reusable packages in pkg. This signals to users that the code is stable and intended for external use. This directory is optional. Small projects can put public code in the root.

Use api when you have protocol definitions. Put protobuf files, JSON schemas, or OpenAPI specs in api. This keeps interface definitions separate from implementation. Generate Go code from these files into pkg or internal.

Use a flat structure when the project is tiny. If you have one binary and a few helper files, put them in the root. Do not create directories until you need them. Complexity should match the problem size.

Use web when you have frontend assets. Put HTML templates, CSS, and JavaScript in web. This separates static files from Go code. Serve these files from your HTTP handler.

The worst organization is the one that fights the import path. If your directory structure makes imports long or confusing, change the structure. Import paths are addresses. Keep them short and clear.

Goroutines are cheap. Channels are not magic. Structure follows function, not convention.

Where to go next