I started learning Go because of the Kafka pet project I was building. My mentor recommended it, and after a few days with the language I understood why. Go is opinionated, fast, and surprisingly easy to get productive in. This series documents what I learn as I go — starting from the basics and moving into things like concurrency, networking, and building real tools.
Why Go Exists
Go was created at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson. The motivation was straightforward: Google's infrastructure was mostly written in C++ and Java, and both were becoming painful at scale — slow compile times, verbose code, and awkward concurrency models.
Go was designed to fix these specific problems:
- Fast compilation — a large Go codebase compiles in seconds, not minutes
- Simple concurrency — goroutines and channels are built into the language
- Readable code — minimal syntax, no classes, no inheritance
- Single binary output — Go compiles to a standalone executable with no runtime dependency
The language launched publicly in 2009 and reached v1.0 in 2012. Today it powers tools like Docker, Kubernetes, Terraform, and Prometheus — essentially the backbone of modern cloud infrastructure.
How Go Compiles and Runs
Unlike Java, Go compiles directly to native machine code — there is no intermediate bytecode or virtual machine.
.go -> go compiler -> native binary (ELF/PE/Mach-O)
This means:
- Startup time is near-instant (no JVM warmup)
- The output is a single self-contained binary — no runtime to install
- Cross-compilation is built in (
GOOS=linux GOARCH=amd64 go build)
The trade-off compared to Java's JIT: Go does not optimize hot code paths at runtime. The compiler does all optimization up front. In practice, Go's performance is excellent for I/O-bound and network workloads, which is where most backend code lives.
Go does have a garbage collector, so you do not manage memory manually (unlike C or Rust). The GC has improved significantly over the years — pause times are now in the microsecond range for most workloads.
Setting Up
Install Go from go.dev. After installation:
go version
# go version go1.22.0 linux/amd64
Create a new module (the Go equivalent of a project):
mkdir hello && cd hello
go mod init hello
go.mod is the module file — it declares the module name and Go version, similar to package.json or pom.xml.
Hello, World
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
go run main.go
# Hello, World!
A few things worth noting immediately:
- Every Go file belongs to a package.
package mainis the entry point package. importpulls in standard library packages.fmthandles formatted I/O.func main()is the entry point — no class wrapper needed.- There are no semicolons. The formatter (
gofmt) enforces this.
Variables and Types
Go is statically typed. Every variable has a type known at compile time.
// explicit type
var name string = "Vu"
var age int = 22
// short declaration (most common inside functions)
city := "Ho Chi Minh City"
// multiple assignment
x, y := 10, 20
The := short declaration infers the type from the right side. It only works inside functions.
Go's basic types:
| Category | Types |
|---|---|
| Integer | int, int8, int16, int32, int64 |
| Unsigned | uint, uint8, uint16, uint32, uint64 |
| Float | float32, float64 |
| String | string |
| Boolean | bool |
| Byte / Rune | byte (alias for uint8), rune (alias for int32) |
int is either 32 or 64 bits depending on the platform — on most modern systems it is 64 bits.
Functions
func add(a int, b int) int {
return a + b
}
// shorthand when params share a type
func multiply(a, b int) int {
return a * b
}
// multiple return values
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
Multiple return values are idiomatic in Go — the second value is almost always an error. This is Go's error handling pattern: no exceptions, just values.
result, err := divide(10, 3)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(result)
The if err != nil pattern appears everywhere in Go code. It's verbose compared to try/catch, but explicit — you always know where errors can happen.
Control Flow
// standard if/else
if x > 10 {
fmt.Println("big")
} else if x > 5 {
fmt.Println("medium")
} else {
fmt.Println("small")
}
// for is the only loop in Go
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// while-style loop
n := 0
for n < 10 {
n++
}
// infinite loop
for {
// break when done
break
}
Go has no while keyword. The for loop covers all cases.
Arrays, Slices, and Maps
// array — fixed size, rarely used directly
arr := [3]int{1, 2, 3}
// slice — dynamic, the type you actually use
nums := []int{10, 20, 30}
nums = append(nums, 40) // add element
fmt.Println(nums[1]) // 20
fmt.Println(nums[1:3]) // [20 30]
// map
scores := map[string]int{
"Alice": 95,
"Bob": 88,
}
scores["Charlie"] = 72
val, ok := scores["Alice"] // ok is false if key doesn't exist
if ok {
fmt.Println(val)
}
Slices are the workhorse of Go. They are backed by an underlying array but can grow dynamically via append. The three-index slice expression s[low:high] gives you a window into the backing array without copying.
Structs
Go has no classes. Instead it uses structs — plain data containers.
type User struct {
Name string
Email string
Age int
}
u := User{
Name: "Dinh Dai Vu",
Email: "vudd@i-stech.net",
Age: 22,
}
fmt.Println(u.Name)
You attach behavior to structs using methods:
func (u User) Greet() string {
return "Hello, I'm " + u.Name
}
// pointer receiver — modifies the original
func (u *User) Birthday() {
u.Age++
}
The (u User) before the function name is the receiver. Use a value receiver when you do not need to modify the struct. Use a pointer receiver (*User) when you do.
Interfaces
Go interfaces are implicit — a type satisfies an interface simply by having the right methods. No implements keyword needed.
type Greeter interface {
Greet() string
}
func printGreeting(g Greeter) {
fmt.Println(g.Greet())
}
// User satisfies Greeter because it has a Greet() method
printGreeting(u)
This is one of Go's best features. It makes code easy to compose and test — you can pass any type that has the required methods, without any inheritance hierarchy.
Packages and Modules
Go organizes code into packages. Everything in the same directory belongs to the same package. Exported names start with a capital letter — lowercase is package-private.
// math/calc.go
package math
func Add(a, b int) int { return a + b } // exported
func helper() int { return 0 } // not exported
The module system (go.mod) manages dependencies. To add an external package:
go get github.com/some/package
Go downloads the dependency and records it in go.mod and go.sum. The go.sum file contains cryptographic checksums — Go verifies every download, which prevents supply-chain attacks.
What's Next
This post covered the fundamentals: how Go compiles, the basic types, functions, error handling, structs, interfaces, and the module system. That's enough to read most Go code.
In the next part I'll go into what makes Go genuinely different from Java and Python: goroutines and channels — Go's concurrency model. This is where Go shines, and it's directly relevant to the Kafka project I'm building alongside this series.