A Confession, and Why It Matters
I ignored Go for years because I thought it was too simple. I was wrong.
Back in 2019, maybe 2020, a colleague pinged me a link to a Go microservice he’d written over a weekend. Fewer than 200 lines. No framework, no ORM, no dependency injection container. Just the standard library and a clean binary you could scp to a server. My reaction? “Cute, but where’s the real code?” I went back to wrestling with Spring Boot YAML files and a 47-dependency pom.xml.
Look, I’d spent years believing that a language’s power came from its abstraction depth. Generics, metaclasses, decorator patterns stacked three layers high. Go had none of that (well, it didn’t have generics until 1.18 in early 2022). A language without inheritance? Without exceptions? Felt like bringing a butter knife to a sword fight.
Here’s what I didn’t understand: Go’s simplicity isn’t a limitation. It’s a weapon. Docker was written in Go. Kubernetes was written in Go. Terraform, Prometheus, CockroachDB, Cloudflare’s edge infrastructure — all Go. Uber rebuilt massive chunks of their backend in Go. When that many billion-dollar systems converge on the same language, maybe the butter knife is actually a scalpel.
Mentor’s note: If you’re coming from Python, Java, or JavaScript, you’ll probably feel friction with Go’s style at first. That friction is the point. Lean into it. By the end of this guide, you’ll see why.
So let me show you everything I wish someone had walked me through when I finally gave Go an honest shot. We’ll go from variables to goroutines to a working HTTP server, with runnable code at every step. No filler sections, no abstract theory without payoff. Just the language, explained by someone who came around the hard way.
Setting Up and Writing Your First Lines
Before anything else: grab Go from go.dev/dl. Installation is painless on macOS, Linux, and Windows. Run go version in your terminal afterward, and you should see something like go1.22.x or newer. That’s it. No virtual environments, no nvm, no SDK manager. One binary, globally available.
Create a directory for your project, cd into it, and run:
mkdir hello-go && cd hello-go
go mod init hello-go
That go mod init command creates a go.mod file — Go’s version of package.json or requirements.txt, except it rarely grows beyond a handful of lines because the standard library covers an absurd amount of ground.
Create a file called main.go and you’re ready. Let me walk you through the fundamentals.
Variables, Types, and the Basics
Go is statically typed, but don’t let that scare you if you’re coming from Python or JavaScript. Its type inference is sharp enough that you’ll rarely write type annotations by hand inside functions.
package main
import "fmt"
func main() {
// Explicit type declaration
var name string = "ByteYogi"
var age int = 5
var pi float64 = 3.14159
// Short variable declaration (type inferred)
language := "Go"
version := 1.22
isAwesome := true
fmt.Println("Name:", name)
fmt.Println("Age:", age)
fmt.Println("Pi:", pi)
fmt.Printf("Learning %s %.2f - Awesome: %t\n", language, version, isAwesome)
// Constants
const maxRetries = 3
const baseURL = "https://api.example.com"
// Zero values (Go initializes variables to their zero value)
var count int // 0
var message string // ""
var flag bool // false
fmt.Printf("Zeros: %d, '%s', %t\n", count, message, flag)
// Arrays and slices
// Arrays have fixed length
var scores [3]int = [3]int{95, 87, 92}
fmt.Println("Scores:", scores)
// Slices are dynamic (more commonly used)
languages := []string{"Go", "Python", "Rust", "TypeScript"}
languages = append(languages, "Zig")
fmt.Println("Languages:", languages)
fmt.Println("First two:", languages[:2])
// Maps
capitals := map[string]string{
"France": "Paris",
"Japan": "Tokyo",
"India": "New Delhi",
}
capitals["Brazil"] = "Brasilia"
fmt.Println("Capitals:", capitals)
// Check if key exists
if capital, ok := capitals["Japan"]; ok {
fmt.Println("Japan's capital:", capital)
}
}
A few things worth pausing on here.
First, the := operator. You’ll see it everywhere in Go code. It declares and assigns a variable in one shot, with the type figured out from whatever sits on the right side. Inside functions, most Go developers use := almost exclusively — the explicit var name type = value form shows up mainly at package level or when you need to declare a variable without immediately assigning it something meaningful.
Second, zero values. When you declare a variable without giving it a value, Go doesn’t leave it as garbage memory (looking at you, C). Integers start at 0, strings at "", booleans at false, pointers at nil. Sounds minor, but it eliminates an entire category of bugs. I’ve probably saved hours of debugging over the past couple years just from this one feature.
Third, slices versus arrays. Arrays in Go have a fixed size baked into their type — [3]int and [5]int are different types entirely. You’ll almost never use raw arrays. Slices, written without a length like []int, are what you reach for. They’re dynamic, they grow with append(), and they’re backed by arrays under the hood. Think of them as Go’s answer to Python lists or JavaScript arrays.
Quick tip: Maps in Go are unordered. If you range over capitals multiple times, the iteration order might differ each run. The Go team did this deliberately to prevent developers from accidentally depending on insertion order.
And notice what’s missing: no semicolons (the compiler inserts them), no parentheses around if conditions, no ternary operator. Go’s designers cut features with a chainsaw rather than a scalpel. Took me a while to appreciate that constraint.
Functions: Multiple Returns and the Error Dance
Here’s where Go starts showing its personality. Functions can return multiple values, and the language uses this mechanism for error handling instead of try-catch exceptions.
Why no exceptions? Because exceptions create invisible control flow. A function three layers deep can throw, and unless you’ve memorized the entire call stack, you won’t know where your program lands. Go forces you to handle errors right where they happen. It’s verbose, yes. It’s also incredibly readable six months later when you’re debugging at 2 AM.
package main
import (
"errors"
"fmt"
"math"
)
// Basic function with multiple return values
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// Named return values
func stats(numbers []float64) (mean, stddev float64) {
if len(numbers) == 0 {
return 0, 0
}
sum := 0.0
for _, n := range numbers {
sum += n
}
mean = sum / float64(len(numbers))
varianceSum := 0.0
for _, n := range numbers {
diff := n - mean
varianceSum += diff * diff
}
stddev = math.Sqrt(varianceSum / float64(len(numbers)))
return // returns named values
}
// Variadic function (accepts any number of arguments)
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Functions as values (closures)
func makeMultiplier(factor int) func(int) int {
return func(x int) int {
return x * factor
}
}
func main() {
// Error handling pattern
result, err := divide(10, 3)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Printf("10 / 3 = %.4f\n", result)
}
_, err = divide(10, 0)
if err != nil {
fmt.Println("Error:", err) // "division by zero"
}
// Stats
data := []float64{4, 8, 15, 16, 23, 42}
mean, std := stats(data)
fmt.Printf("Mean: %.2f, StdDev: %.2f\n", mean, std)
// Variadic
fmt.Println("Sum:", sum(1, 2, 3, 4, 5))
// Closure
double := makeMultiplier(2)
triple := makeMultiplier(3)
fmt.Println("Double 5:", double(5)) // 10
fmt.Println("Triple 5:", triple(5)) // 15
}
Let me break this down piece by piece for you.
divide returns two things: the result and an error. When the operation succeeds, the error comes back as nil (Go’s null). When it fails, you get a real error value. Every caller is forced to deal with it or explicitly ignore it with _. You’ll see this if err != nil pattern so often it’ll be burned into your muscle memory within a week.
stats uses named return values — mean and stddev are declared right in the function signature. Inside the function body, they behave like regular variables. The bare return at the end sends back whatever those variables hold. Opinions on named returns are mixed in the community; I’d say use them when the meaning isn’t obvious from context, skip them for simple one-liners.
sum demonstrates variadic functions. The ...int syntax lets you pass any number of integers, and inside the function, nums is just a regular slice. Similar to Python’s *args, if you’ve used that.
makeMultiplier returns a closure — a function that captures the factor variable from its enclosing scope. Closures work exactly the way you’d expect from JavaScript or Python. Nothing exotic here.
Common mistake: Forgetting to check errors. Go won’t yell at you (in most cases) for ignoring an error return value. Linters like errcheck and golangci-lint will, though. Install golangci-lint early. Trust me on this one.
Structs, Methods, and Interfaces
Go doesn’t have classes. No inheritance. No constructors, no destructors, no this keyword. If that sounds limiting, bear with me — what it has instead might actually be better for most real-world code.
Structs hold data. Methods attach behavior to structs. Interfaces define contracts. And here’s the crucial bit: interfaces in Go are satisfied implicitly. You never write implements Printable or extends AbstractBase. If your struct has the right methods, it satisfies the interface. Automatically. The compiler checks it for you.
package main
import "fmt"
// Define a struct
type Task struct {
ID int
Title string
Description string
Done bool
Priority int
}
// Method with a value receiver (does not modify the struct)
func (t Task) Summary() string {
status := "pending"
if t.Done {
status = "done"
}
return fmt.Sprintf("[%s] #%d: %s (priority %d)", status, t.ID, t.Title, t.Priority)
}
// Method with a pointer receiver (can modify the struct)
func (t *Task) Complete() {
t.Done = true
}
func (t *Task) SetPriority(p int) {
if p >= 1 && p <= 5 {
t.Priority = p
}
}
// TaskList manages a collection of tasks
type TaskList struct {
tasks []Task
nextID int
}
func NewTaskList() *TaskList {
return &TaskList{nextID: 1}
}
func (tl *TaskList) Add(title, desc string) Task {
task := Task{
ID: tl.nextID,
Title: title,
Description: desc,
Priority: 3,
}
tl.tasks = append(tl.tasks, task)
tl.nextID++
return task
}
func (tl *TaskList) Pending() []Task {
var result []Task
for _, t := range tl.tasks {
if !t.Done {
result = append(result, t)
}
}
return result
}
// Interfaces are satisfied implicitly
type Summarizer interface {
Summary() string
}
func printSummary(s Summarizer) {
fmt.Println(s.Summary())
}
func main() {
list := NewTaskList()
list.Add("Learn Go basics", "Variables, functions, structs")
list.Add("Build HTTP server", "Use net/http package")
list.Add("Deploy to production", "Docker + Kubernetes")
for i := range list.tasks {
printSummary(list.tasks[i]) // Task satisfies Summarizer
}
list.tasks[0].Complete()
fmt.Println("\nPending tasks:", len(list.Pending()))
}
Walk through this with me, section by section.
Task is a struct with five fields. Nothing fancy. In Java, this would require a class file, a constructor, maybe Lombok annotations or record syntax. In Go, you write the struct and you're done. Fields are exported (public) if they start with an uppercase letter, unexported (private) if lowercase. That's the entire visibility system. No public/private/protected keywords.
Methods are attached via receivers. func (t Task) Summary() uses a value receiver — it gets a copy of the struct, so it can't modify the original. func (t *Task) Complete() uses a pointer receiver — it gets a reference, so changes persist. Rule of thumb: if your method needs to mutate state, use a pointer receiver. If it only reads, either works, but pointer receivers are still common to avoid copying large structs.
Now look at the Summarizer interface. It declares a single method: Summary() string. Did Task ever declare that it implements Summarizer? Nope. It just... has a Summary() method that returns a string. That's enough. The compiler sees the match and allows Task values wherever a Summarizer is expected.
Why does this matter? Because it means you can define interfaces after the types they apply to exist. A package author doesn't need to anticipate every possible interface. You can write your own interface in your own code, and any type from any package that happens to have the right methods will satisfy it. Incredibly powerful for testing — you can mock anything without frameworks.
Worth knowing: NewTaskList() is a convention, not a language feature. Go has no constructors. Functions named New... or NewXxx serve as factory functions by community convention. You'll see this pattern in nearly every Go codebase and standard library package.
Goroutines and Channels: Where Go Gets Dangerous
Alright, here's the headline feature. The reason Go exists, if we're being honest.
Goroutines are lightweight concurrent functions managed by Go's runtime. Not OS threads. A single Go process can spawn hundreds of thousands of goroutines on modest hardware, each consuming only a few kilobytes of stack space that grows and shrinks as needed. Launching one costs roughly the same as a function call.
Channels are typed conduits that let goroutines talk to each other safely — no shared memory, no manual locking (most of the time), no data races if you're disciplined.
Let me show you both, starting simple and escalating to a worker pool.
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Simulate fetching data from a URL
func fetchURL(url string, ch chan<- string) {
delay := time.Duration(rand.Intn(3000)) * time.Millisecond
time.Sleep(delay)
ch <- fmt.Sprintf("Fetched %s in %v", url, delay)
}
// Worker pool pattern
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(500 * time.Millisecond)
results <- job * 2
}
}
func main() {
// Basic goroutine with channel
ch := make(chan string, 3)
urls := []string{
"https://api.example.com/users",
"https://api.example.com/posts",
"https://api.example.com/comments",
}
// Launch goroutines concurrently
for _, url := range urls {
go fetchURL(url, ch)
}
// Collect results
for range urls {
fmt.Println(<-ch)
}
// Worker pool
fmt.Println("\n--- Worker Pool ---")
jobs := make(chan int, 10)
results := make(chan int, 10)
var wg sync.WaitGroup
// Start 3 workers
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send 9 jobs
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
// Wait for workers and collect results
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println("Result:", r)
}
}
Let's unpack what's happening, because this is dense and important.
Part one: concurrent fetching. We create a buffered channel (make(chan string, 3)) that can hold three string values. For each URL, go fetchURL(url, ch) launches a goroutine — just the word go before a function call. That's it. All three fetches run concurrently. Each one sleeps for a random duration (simulating network latency), then sends its result into the channel. Back in main, we read from the channel three times. If a result isn't ready yet, the read blocks until one arrives. No callbacks, no promises, no async/await.
Part two: the worker pool. Real systems don't just fire goroutines indiscriminately. You need to control concurrency. The worker pool pattern uses two channels — jobs for input, results for output — and a sync.WaitGroup to know when all workers finish.
Each worker loops over the jobs channel with for job := range jobs. When the channel is closed and drained, the loop exits. defer wg.Done() signals the WaitGroup on exit. Meanwhile, an anonymous goroutine waits for all workers to finish, then closes the results channel, which causes the final for r := range results loop in main to terminate.
Honestly, this pattern probably handles 70% of the concurrency scenarios you'll encounter in production Go code. Variations exist (fan-out/fan-in, pipeline stages, select with timeouts), but master this one first.
Gotcha alert: Sending to or receiving from an unbuffered channel blocks until the other side is ready. Sending to a full buffered channel also blocks. Receiving from a closed channel returns the zero value immediately. These rules are simple on paper and deceptively tricky in practice. When in doubt, draw the channel interactions on a whiteboard before coding.
Channel Direction Types
Notice the arrow notation in the function signatures:
chan<- string— send-only channel (the function can only write to it)<-chan int— receive-only channel (the function can only read from it)chan int— bidirectional (can read and write)
These constraints are enforced at compile time. You might think they're optional decoration, but they catch bugs early and communicate intent to anyone reading your code. Use them. Future-you will be grateful.
Building an HTTP Server (No Framework Needed)
When I first heard that Go's standard library includes a production-grade HTTP server, I didn't believe it. Coming from Node.js (Express) and Python (Flask/Django), "standard library" and "production-grade" didn't belong in the same sentence. But Go's net/http package is the real deal. Cloudflare runs it. So does the default Kubernetes API server, essentially.
Here's a small message board API — GET and POST endpoints, JSON encoding, mutex-based concurrency safety, and logging middleware. All standard library, zero external packages.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
type Message struct {
Text string `json:"text"`
Author string `json:"author"`
Timestamp time.Time `json:"timestamp"`
}
var (
messages []Message
mu sync.RWMutex
)
func handleGetMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
mu.RLock()
defer mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
func handlePostMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var msg Message
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
msg.Timestamp = time.Now()
mu.Lock()
messages = append(messages, msg)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg)
}
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}
}
func main() {
messages = []Message{
{Text: "Welcome to the Go server!", Author: "system", Timestamp: time.Now()},
}
http.HandleFunc("/messages", loggingMiddleware(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleGetMessages(w, r)
case http.MethodPost:
handlePostMessage(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
addr := ":8080"
fmt.Printf("Server listening on %s\n", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
Run it with go run main.go and hit http://localhost:8080/messages in your browser. You'll get a JSON array with the welcome message. Post to the same URL with curl:
curl -X POST http://localhost:8080/messages \
-H "Content-Type: application/json" \
-d '{"text": "Hello from curl!", "author": "dev"}'
A few things I want to call your attention to:
sync.RWMutex— Readers don't block each other (RLock), but writers get exclusive access (Lock). Crucial when your server handles concurrent requests hitting the same data.- Struct tags — The backtick annotations like
`json:"text"`tell Go's JSON encoder how to map struct fields to JSON keys. Without them, you'd get"Text"instead of"text"in your API responses. Seems small; drives frontend devs crazy when you forget. - Middleware as a higher-order function —
loggingMiddlewarewraps a handler and returns a new handler. Same pattern as Express middleware or Python decorators, just without syntactic sugar. defer— Schedules a function call to run when the surrounding function returns.defer mu.RUnlock()ensures the lock is always released, even if the function panics. Usedeferfor cleanup. It'll save you from resource leaks.
When your routes get more complex — path parameters, route groups, middleware chains — you'll want a lightweight router like chi or gin. But for small services and learning? The standard library is more than enough. I've shipped internal tools at work with nothing but net/http and they've been running for over a year without issues.
Go Modules and Project Structure
Real Go projects aren't a single main.go file. Once you're past the "hello world" phase, you'll want to organize code into packages. Here's what a typical small-to-medium Go project looks like in mid-2026:
myapp/
go.mod
go.sum
main.go
internal/
handler/
messages.go
model/
message.go
middleware/
logging.go
pkg/
validator/
validator.go
internal/— Code here can only be imported by packages rooted at the parent ofinternal. The compiler enforces this. It's Go's answer to "how do I keep implementation details private."pkg/— Optional. Some teams use it for code intended to be importable by external projects. Others skip it entirely. Opinions vary.go.sum— Auto-generated checksums of your dependencies. Don't edit it manually, but do commit it to version control.
Adding an external dependency is straightforward:
go get github.com/go-chi/chi/v5
Go downloads the module, adds it to go.mod, and you're set. No npm install --save ceremony. And because Go compiles to a single static binary, your deployment artifact doesn't carry a node_modules folder or a virtual environment. Just the binary. scp it, run it, done.
Testing (It's Built In, and It's Good)
Go includes a testing framework in the standard library. No pytest, no Jest, no JUnit. Just create a file ending in _test.go, write functions starting with Test, and run go test.
// divide_test.go
package main
import "testing"
func TestDivide(t *testing.T) {
result, err := divide(10, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := 3.3333
if result-expected > 0.001 {
t.Errorf("expected ~%.4f, got %.4f", expected, result)
}
}
func TestDivideByZero(t *testing.T) {
_, err := divide(10, 0)
if err == nil {
t.Fatal("expected error for division by zero")
}
}
Run with go test -v ./... to test all packages recursively. Add -cover for coverage. Add -race to detect data races (seriously, use -race during development — it's caught issues for me that I never would've found through manual testing).
The tooling isn't glamorous. No fancy assertion libraries, no BDD syntax. But it's fast, it's consistent, and it's always there. For more expressive assertions, the community leans toward testify, which adds assert and require helpers. Worth grabbing once the standard library style starts feeling verbose.
Common Stumbling Blocks for Newcomers
Having walked a few colleagues through their first weeks with Go, I can tell you where people tend to trip. Save yourself some frustration:
- Unused variables and imports are compile errors. Not warnings. Errors. Your code won't compile if you declare a variable and don't use it. Annoying during prototyping, valuable in production. Use
_to explicitly ignore values you don't need. - Exported vs unexported names. Uppercase first letter = exported (visible outside the package). Lowercase = unexported. There's no
publickeyword. Every Go developer has written a struct field with a lowercase letter and then wondered why it doesn't show up in JSON output. You'll do it once. Maybe twice. - Nil pointer panics. Go has nil, and nil pointer dereferences cause runtime panics. Always check for nil when dealing with pointers, interface values, or function results that might return nil.
- Shadowing with
:=in nested scopes. If you use:=inside an if block, it creates a new variable that shadows the outer one. The outer variable remains unchanged. This one bites experienced developers too. - Goroutine leaks. Spawning goroutines is cheap. Forgetting to give them a way to exit is expensive. Always plan how your goroutines terminate — via channel close, context cancellation, or WaitGroup.
What to Learn Next
You've got the foundations. From here, the path branches in several directions depending on what you're building:
contextpackage — For cancellation, timeouts, and request-scoped values. Mandatory knowledge for any production Go service. Every HTTP handler should accept a context.- Generics (Go 1.18+) — Type parameters arrived in early 2022. They're more conservative than generics in Java or Rust, but they solve real problems with type-safe collections and utility functions.
chiorginrouters — Whennet/http's routing feels too primitive.chiis particularly nice because it's compatible with the standard library's handler signatures.- Database access —
database/sqlin the standard library, orsqlxfor a slightly friendlier layer. For ORMs,GORMis popular, though many Go developers prefer writing SQL directly. - Docker deployment — A Go binary in a
FROM scratchDocker image can be under 15 MB. Multi-stage builds make this trivial. Probably the best deployment story of any compiled language.
Where Go Fits — My Honest Take
I've been writing Go as my primary backend language for about three years now, after that initial period of dismissal. So here's my unfiltered take.
Go isn't the best language at anything, really. Rust is safer. Python is more expressive for scripting and data science. Java has a deeper bench of enterprise libraries. TypeScript gives you full-stack uniformity with a single runtime. Haskell's type system is more powerful. Zig gives you more control over memory.
But Go might be the best language at everything put together. It compiles in seconds. It deploys as a single binary. Its concurrency model is genuinely elegant, not just "workable." The standard library covers HTTP, JSON, crypto, testing, profiling, and templating without external dependencies. The learning curve is measured in days, not months. Code written by a junior developer looks remarkably similar to code written by a staff engineer, because the language simply doesn't have enough features to enable wild stylistic divergence.
For backend services, CLIs, DevOps tooling, and anything that needs to handle lots of concurrent I/O? I'd pick Go over most alternatives in 2026. Not because it's trendy (it was trendier five years ago, honestly), but because it gets out of my way and lets me ship.
For data science, machine learning, rapid prototyping, or highly dynamic scripting? I'd still reach for Python. For frontend? TypeScript, obviously. For embedded or systems programming where you genuinely need zero-cost abstractions? Rust.
Go occupies a specific niche, and it occupies it brilliantly. My mistake was judging it before I understood what that niche was. If you're building services that need to be fast, reliable, and maintainable by a team — and you want to spend your energy on the problem rather than the language — give Go a real shot. Not a weekend glance. A real project. Build something that talks to a database and handles concurrent requests. You'll feel the difference.
That colleague's 200-line microservice from 2019? It's still running. No dependency updates, no security patches to the runtime, no framework migrations. Just a binary on a server, doing its job. I think about that more than I'd like to admit.