How to get started with Go if you already know how to program
A practical guide to getting started with Go if you come from Python, Java, or Kotlin. Installation, structure, modules, functions, structs, and testing.

If you already program in Python, Java, or Kotlin, you don’t need another “Hello World” tutorial. What you need is to understand the opinions Go makes on your behalf, and why it makes them. Go is a language that forces you to do things in a specific way, and if you try to fight that — as I did at first — the experience is rough. But if you accept it, everything clicks with a speed that surprises you.
Go has no classes, no exceptions, no complex generics, no inheritance. And that’s not an accidental limitation: it’s a deliberate design decision. Each of those absences exists to eliminate an entire category of problems that Google encountered maintaining code at scale. I believe your job when starting out isn’t to ask “where’s the inheritance?” but to understand what alternative Go proposes and why it works. Though I’ll admit it’s hard to accept at first.
This article is the guide that would have saved me time when I started with Go coming from Kotlin and Java. It goes straight to what matters.
Installation: five minutes and you’re ready
Go has one of the cleanest installations of any language. No mandatory version managers, no conflicts with the system version, no drama.
macOS
// If you use Homebrew:
// brew install goOr download the .pkg from go.dev/dl and install it directly. Homebrew is more convenient for updating, but the official installer works just as well.
Linux
# Download the latest version (adjust the version as needed)
wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gzAdd this to your .bashrc or .zshrc:
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/binWindows
Download the .msi from go.dev/dl and run the installer. Environment variables are configured automatically.
Verify the installation
go version
# go version go1.23.0 darwin/arm64If you see the version, you’re ready. And being honest, that’s already a relief. No SDK manager needed, no choosing between JDK 17, 21, or 22, no pyenv to avoid conflicts. One version, one binary, it works.
Your first project: go mod, main.go, go run
In Go you don’t create a project with a generator or a CLI framework. You create a directory, initialize a module, and write code.
mkdir my-first-project
cd my-first-project
go mod init github.com/your-username/my-first-projectThat go mod init creates a go.mod file, which is the equivalent of Maven’s pom.xml, Gradle’s build.gradle.kts, or Python’s requirements.txt. But simpler:
module github.com/your-username/my-first-project
go 1.23.0That’s all. No 200-line XML files. No plugins. No configuration sections you never touch but that break if you remove them.
Now create the main.go file:
package main
import "fmt"
func main() {
fmt.Println("It works")
}And run it:
go run main.go
# It worksOr compile it:
go build -o my-app
./my-app
# It worksgo run compiles and executes in one step. go build generates a static binary you can copy to any machine with the same architecture and run without dependencies. No JVM, no Python interpreter, no Docker to make it work. A single binary file.
If you come from Java or Kotlin, that detail will change how you deploy applications. If you come from Python, you’ll stop fighting with virtual environments and dependency versions in production.
Packages and visibility: the convention that surprises everyone
In Go, the visibility of an identifier is decided by the first letter of its name. Uppercase: exported (public). Lowercase: unexported (private to the package).
package user
// Exported: accessible from other packages
type User struct {
Name string
Email string
age int // unexported: only visible within the "user" package
}
// Exported
func NewUser(name, email string, age int) User {
return User{
Name: name,
Email: email,
age: age,
}
}
// Unexported
func validateEmail(email string) bool {
return len(email) > 0 // simplified
}No public, private, protected, internal keywords. No __init__.py. No export or module.exports. The convention is the mechanism.
The first time you see it, it seems strange. Technically I wasn’t wrong to think it was an odd convention. But after a week, it seems almost absurd that other languages need a keyword for something resolved with a capital letter. You open a file, see a lowercase function, and immediately know it’s internal. No need to look for the access modifier.
Package structure
Each directory is a package. There’s no mandatory src directory. The most basic structure is:
my-project/
├── go.mod
├── main.go
├── user/
│ └── user.go
└── repository/
└── repository.goTo import a package from your own project:
package main
import (
"fmt"
"github.com/your-username/my-first-project/user"
)
func main() {
u := user.NewUser("Roger", "roger@example.com", 30)
fmt.Println(u.Name)
}Notice that the import uses the full module path + directory. No magic aliases or barrel file conventions. If you need to dig deeper into organizing something more serious, I have a dedicated article on project structure and another on Go modules.
Variables, types and zero values
Go is a statically typed language, but you don’t always need to declare the type. It has type inference that works well in most cases.
Explicit declaration
var name string = "Roger"
var age int = 30
var active bool = trueShort declaration (what you’ll use 90% of the time)
name := "Roger"
age := 30
active := trueThe := operator declares and initializes. It only works inside functions, not at the package level. It’s the equivalent of Kotlin’s val with type inference.
Zero values: everything has a default value
In Go, variables declared without initialization aren’t null or undefined. They have a zero value determined by their type:
var s string // ""
var n int // 0
var f float64 // 0.0
var b bool // false
var p *int // nil (only pointers)This eliminates an entire category of NullPointerException. If you declare a string, it always has a value: the empty string. No surprises. Well, almost: pointers can be nil, so the problem doesn’t completely disappear, but it’s greatly reduced. If you come from Java, think of Go doing by default what you used to do manually when initializing fields in the constructor. If you come from Kotlin, it’s like everything having a default value without you needing to declare it.
Basic types
// Integers
var a int // size depends on platform (32 or 64 bits)
var b int64 // 64 bits explicitly
var c uint // unsigned integer
// Decimals
var d float64 // the most used
var e float32
// Text
var f string // immutable, UTF-8 by default
// Boolean
var g bool
// Byte and Rune
var h byte // alias for uint8
var i rune // alias for int32 (a Unicode character)Go doesn’t do implicit conversions between numeric types. If you have an int and need an int64, you convert explicitly:
var x int = 42
var y int64 = int64(x)This is annoying for the first few days. Then you appreciate the compiler forcing you to be explicit rather than hiding conversions that lose precision.
Functions: multiple returns and named returns
Functions in Go have a feature that in other languages requires libraries or workarounds: native multiple returns.
Basic function
func add(a, b int) int {
return a + b
}If parameters are the same type, you can group them. a, b int instead of a int, b int.
Multiple returns
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}And calling it:
result, err := divide(10, 3)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)This value, error pattern is the heart of Go. No try/catch, no exceptions, no throw. Every function that can fail returns an error as the second value, and the calling code decides what to do with it. Immediately. Not three levels up in a generic catch. That’s where Go’s philosophy becomes tangible.
If you come from Java, at first you feel like you’re writing more code. And that’s true, I won’t deny it. But it’s also true that you never lose sight of where something can fail. No exceptions silently propagating to a generic handler that logs “something went wrong” and swallows the error.
Named returns
Go allows naming return values:
func parseConfig(path string) (config Config, err error) {
data, err := os.ReadFile(path)
if err != nil {
return // returns zero values for config and the current error
}
err = json.Unmarshal(data, &config)
return // returns populated config and err (nil if everything went well)
}Named returns are initialized with their zero value and you can use return without arguments. They’re useful in short functions, but in longer functions they can make the code harder to follow. My recommendation: use them when the function is 15 lines or fewer. In larger functions, be explicit about what you return.
Structs and methods
Go has no classes. It has structs with associated methods. If you come from object-oriented programming, think of structs as classes without inheritance, without magic constructors, and — being honest — without half the complexity you probably never needed.
Define a struct
type Task struct {
ID int
Title string
Completed bool
}Create instances
// Literal form (the most common)
t := Task{
ID: 1,
Title: "Write Go article",
Completed: false,
}
// Unmentioned fields take their zero value
t2 := Task{Title: "Review code"}
// t2.ID == 0, t2.Completed == false
// Pointer to struct
t3 := &Task{
ID: 3,
Title: "Deploy service",
}No new, no constructors with fifteen parameters, no builders. If you need validation on creation, the convention is to create a constructor function:
func NewTask(title string) (Task, error) {
if title == "" {
return Task{}, fmt.Errorf("title cannot be empty")
}
return Task{
Title: title,
}, nil
}Methods
Methods are associated with a type through a receiver:
// Value receiver (doesn't modify the original struct)
func (t Task) IsComplete() bool {
return t.Completed
}
// Pointer receiver (can modify the struct)
func (t *Task) Complete() {
t.Completed = true
}And they’re used exactly as you’d expect:
task := Task{Title: "Learn Go"}
fmt.Println(task.IsComplete()) // false
task.Complete()
fmt.Println(task.IsComplete()) // trueThe difference between value and pointer receivers is fundamental:
- Value: receives a copy of the struct. Useful for methods that only read.
- Pointer: receives a reference. Necessary for methods that modify state.
If you come from Java or Kotlin, think of func (t *Task) Complete() as the equivalent of an instance method that modifies this. And func (t Task) IsComplete() is like making a copy of the object before consulting it.
The convention in Go is that if any method on the type needs a pointer receiver, all methods should use a pointer receiver. This maintains consistency and avoids subtle bugs.
Error handling: just enough to get started
Error handling in Go is a topic that deserves a complete article, and I have a dedicated one at Go error handling. But you need to understand the basics from day one because you’ll be writing if err != nil in practically every function.
The basic pattern
file, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("error opening config: %w", err)
}
defer file.Close()Three important things here:
if err != nil: this is the universal pattern. Every operation that can fail returns an error, and you check it immediately.%w(wrapping): wraps the original error to add context without losing the underlying error information. This is key for debugging.defer: schedules the execution offile.Close()for when the current function ends. It’s the equivalent of Java’stry-with-resourcesor Python’s context manager, but more flexible.
Creating your own errors
import "errors"
var ErrUserNotFound = errors.New("user not found")
func FindUser(id int) (User, error) {
// ... lookup logic
if !found {
return User{}, ErrUserNotFound
}
return user, nil
}And handling it:
user, err := FindUser(42)
if errors.Is(err, ErrUserNotFound) {
// handle specific case
}Yes, it’s more verbose than a catch (UserNotFoundException e). But you know exactly which line produces the error, which function returned it, and what context was added at each level. No 50-line stack traces where the real error is on line 37 among a sea of Spring frames. I think that trade-off is worth it, though I understand not everyone sees it that way at first.
For a full deep-dive into custom errors, wrapping, errors.Is, errors.As, and advanced patterns, read the Go error handling article.
Your first test: the testing package
Go includes a testing framework in the standard library. No need to install JUnit, no need for pytest, no configuration needed.
Conventions
- Test files are named
*_test.go - Test functions start with
Testand receive*testing.T - They’re run with
go test
Complete example
Suppose you have a file calculator.go:
package calculator
func Add(a, b int) int {
return a + b
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}The test goes in calculator_test.go, in the same directory:
package calculator
import (
"math"
"testing"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; expected 5", result)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := 3.3333
if math.Abs(result-expected) > 0.001 {
t.Errorf("Divide(10, 3) = %f; expected %f", result, expected)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error when dividing by zero")
}
}Run the tests:
go test ./...
# ok github.com/your-username/my-project/calculator 0.003s./... runs tests for all packages in the project. Without that convention, you’d have to specify each package manually.
Table-driven tests
The idiomatic Go pattern for testing multiple cases is table-driven tests:
func TestAdd_TableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positives", 2, 3, 5},
{"with zero", 0, 5, 5},
{"negatives", -1, -2, -3},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; expected %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}t.Run creates subtests with names, which makes the output clear when something fails:
--- FAIL: TestAdd_TableDriven/negatives (0.00s)
calculator_test.go:25: Add(-1, -2) = -3; expected -4You know exactly which case failed without reading a 200-line log. If you come from JUnit with @ParameterizedTest, it’s the same concept but without annotations or extra dependencies.
Coverage
go test -cover ./...
# ok github.com/your-username/my-project/calculator 0.003s coverage: 85.7%
# To see which lines aren't covered:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.outThe last command opens a browser with a visual coverage map. Green: covered. Red: not covered. Without configuring Jacoco, without Gradle plugins, without generating XML reports that another tool then parses.
Tools that come built in
One of the biggest advantages of Go is that the tools are integrated. They’re not third-party plugins that need to be configured, versioned, and maintained.
go fmt
go fmt ./...Formats all project code according to the Go standard. No debates about tabs vs spaces, no Prettier config files, no CI failing because someone didn’t run the formatter. In Go, tabs are used, period. Formatting is part of the language, not a team opinion. And being honest, having that debate settled out of the box is a gift.
go vet
go vet ./...Analyzes code looking for common errors: incorrect arguments in fmt.Printf, forgotten error checks, things that compile but are probably bugs.
go mod tidy
go mod tidyCleans up go.mod and go.sum: adds missing dependencies and removes unused ones. It’s the equivalent of running npm prune + npm dedupe in a single command.
All together
The typical sequence before committing:
go fmt ./...
go vet ./...
go test ./...Three commands. No task runners, no 500-line Makefiles, no CI scripts that install tools not included with the language. Everything is already there.
What will surprise you coming from other languages
Every language has its friction when you start. These are the ones that cost me the most, and I think most people experience something similar:
From Java/Kotlin
- No inheritance. Composition and interfaces. Always. At first it seems limiting, but then you discover your designs end up being simpler and easier to change.
- No exceptions. The
if err != nilis verbose, but explicit. No handlers three levels away that swallow errors. - No complex generics. Go 1.18 added generics, but they’re basic compared to Java’s. This is intentional.
- Fast compilation. A medium project compiles in seconds. Not minutes. The feedback loop is immediate.
From Python
- Static typing. After years with optional types in Python, Go forces you to declare types. At first it’s annoying, then you appreciate the compiler catching errors before running.
- No virtual environment. No
venv, noconda, no version conflicts. One binary, zero runtime dependencies. - No magic. No decorators, no metaclasses, no monkey patching. The code does what it says. Nothing more, nothing less.
- Speed. A Go program is typically between 10x and 100x faster than the Python equivalent. For automation scripts it might not matter. For services with real traffic, it’s transformative.
The language speaks for itself
This is just the entry point. Go has much more underneath, and the really interesting stuff starts when you go from writing standalone functions to building real projects. From here, I recommend continuing with the complete fundamentals to get a broader picture, then understanding modules and project structure for when your code grows beyond a single main.go, and reading Go error handling as soon as possible to master the pattern you’ll repeat the most.
Go is learned by building. The official documentation is excellent, the standard library covers 80% of what you need, and the integrated tooling eliminates all the configuration friction that in other ecosystems steals hours from you. My recommendation: set up a small project, write tests, deploy a binary, and measure. The first time you compile a service, put it in a 15 MB container, and watch it start in less than a second, I think you’ll understand why so many people stick with it.


