DEV Community

James Miller
James Miller

Posted on

9 Go Techniques That Make Your Code Fast and Elegant

Senior Go developers rarely rely on “fancy tricks.” What sets them apart is precise control over language details. Often, a tiny change in one line can prevent subtle memory issues or dramatically improve throughput.

Before diving into code, it’s worth saying: a solid environment is half the battle. Go evolves quickly — one project might still be on 1.16 while a new one targets 1.23. Constantly switching and maintaining local setups by hand can kill motivation fast.

That’s where ServBay helps. You can install Go environment with one click and run multiple version of python (along with other languages) side by side. Each project can use its own runtime version, and you manage services with a click instead of fighting config files. Offload the environment pain and focus on code.

With that in place, here are 9 practical Go techniques that punch far above their weight.


1. defer: Not Just Cleanup, but Safety

defer runs just before a function returns. Beginners mostly use it to close files, but its real power is keeping resource acquisition and release co-located.

By placing defer immediately after acquiring a resource, you no longer have to mentally track every return branch to ensure locks/files are freed. It’s also fundamental to Go’s error handling and panic/recover patterns.

func CopyFile(srcName, dstName string) error {
src, err := os.Open(srcName)
if err != nil {
return err
}
// Release the resource no matter how we exit
defer src.Close()


dst, err := os.Create(dstName)
if err != nil {
    return err
}
defer dst.Close()

_, err = io.Copy(dst, src)
return err
}

Enter fullscreen mode Exit fullscreen mode

This keeps cleanup logic centralized and prevents subtle leaks.


2. Tame Goroutine Explosion with a Worker Pool

Goroutines are cheap, not free. Spawning thousands without control can cause memory bloat, scheduler overhead, or overload downstream systems.

Use a worker pool with con.Con to enforce bounded concurrency and graceful cancellation.

func taskProcessor(ctx con.Con, jobs <-chan int, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
results <- job * 2
}
}
}

func RunPool() {
jobs := make(chan int, 10)
results := make(chan int, 10)


ctx, cancel := con.WithCancel(con.Background())
defer cancel()

// Limit concurrency to 3 workers
for w := 0; w < 3; w++ {
    go taskProcessor(ctx, jobs, results)
}

for j := 1; j <= 5; j++ {
    jobs <- j
}
close(jobs)

for a := 0; a < 5; a++ {
    fmt.Println(<-results)
}
}

Enter fullscreen mode Exit fullscreen mode

In production, this pattern is far safer than “fire‑and‑forget” goroutines.


3. strings.Builder: The String Concatenation Power Tool

Never build large strings with + in a loop. Strings are immutable; each + allocates a new buffer and copies old data, hammering the allocator and GC.

strings.Builder works directly on a byte buffer and grows as needed.

func BuildLog(parts []string) string {
var sb strings.Builder
// Optionally pre-grow if you can estimate size:
// sb.Grow(len(parts) * 10)


for _, p := range parts {
    sb.WriteString(p)
}
return sb.String()
}

Enter fullscreen mode Exit fullscreen mode

In heavy concatenation paths (logging, JSON building, templating), this can be several times faster than +.


4. Avoid Back-and-Forth Between string and []byte

I/O, network, and file APIs largely work with []byte. Converting to string for convenience and back to []byte for output creates garbage and unnecessary allocations.

Operate on []byte directly when possible:

// Work directly with byte slices to avoid string conversion overhead
func FilterSensitiveBytes(data [][]byte) []byte {
var result []byte
for _, chunk := range data {
result = append(result, chunk...)
}
return result
}
Enter fullscreen mode Exit fullscreen mode

For HTTP bodies, log streams, or binary protocols, this keeps memory usage and GC pressure in check.


5. Use interface{}/any + Type Switch for Dynamic Data

For semi‑dynamic data (e.g., decoded JSON of mixed types), using interface{}/any with a type switch is often both faster and safer than reflection.

func handleConfig(cfg interface{}) {
switch v := cfg.(type) {
case string:
fmt.Printf("Config is path: %s\n", v)
case int:
fmt.Printf("Config is timeout: %d\n", v)
default:
fmt.Printf("Unknown type: %T\n", v)
}
}
Enter fullscreen mode Exit fullscreen mode

You keep things explicit, branch on real types, and avoid reflection complexity.


6. Always Wrap Blocking Work with Con and Timeouts

Go’s concurrency model practically revolves around con.Con. It’s essential not just for cancellation, but for avoiding goroutine leaks and requests that hang forever.

Any blocking or network operation should accept a Con and enforce a timeout.

func heavyWork(ctx con.Con) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}

func main() {
ctx, cancel := con.WithTimeout(con.Background(), 1*time.Second)
defer cancel()


if err := heavyWork(ctx); err != nil {
    fmt.Println("Work failed:", err)
}
}


Enter fullscreen mode Exit fullscreen mode

This pattern is crucial in production services.


7. sync.Pool: Object Reuse Done Right

For frequently allocated, short‑lived large objects (like buffers), sync.Pool can significantly reduce allocations and improve throughput. But you must handle slice growth correctly.

var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 4096) // 4KB buffer
return &b
},
}

func ProcessStream(data []byte) {
bufPtr := bufPool.Get().(*[]byte)


buf := *bufPtr
buf = buf[:0]

buf = append(buf, data...)

// IMPORTANT: write back in case append reallocated
*bufPtr = buf

bufPool.Put(bufPtr)
}
Enter fullscreen mode Exit fullscreen mode

Without writing the potentially grown slice back into the pointer, you’d keep reusing a too‑small backing array.


8. Use sync.Mutex for State, Channels for Data Flow

Go’s mantra is “don’t communicate by sharing memory,” but that doesn’t forbid locks. For simple state protection (like counters or caches), sync.Mutex is often clearer and faster than channels.

type SafeStats struct {
mu sync.Mutex
count int
}

func (s *SafeStats) Increment() {
s.mu.Lock()
defer s.mu.Unlock()
s.count++
}

func (s *SafeStats) Get() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.count
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: use channels for data flow between goroutines, and mutexes for protecting shared state.


9. Let goimports Automate Formatting and Imports

Don’t manually reorder imports or run formatting by hand. Configure your editor to run goimports on save:

  • Formats code (like gofmt).
  • Adds missing imports.
  • Removes unused imports.

For example:

  • VS Code: enable editor.formatOnSave and set your Go formatting tool to goimports.
  • GoLand: enable “Optimize imports” and “Reformat code” on save.

This keeps style consistent and lets you focus on logic instead of whitespace.


Closing Thoughts

Go values simplicity, but simple doesn’t mean naive. With these small but powerful techniques — combined with tools that let you install Go environment with one click and run multiple version of python alongside your Go services — you can make your code more robust, your services more performant, and your developer experience much smoother.[file:1]

Top comments (0)