Please purchase the course to watch this video.

Full Course
Build flags in Go are a versatile tool that allow developers to manage the features and functionality of their applications efficiently. They enable selective compilation of code, permitting the inclusion or exclusion of specific features based on build tags. By utilizing this mechanism, developers can easily experiment with new functionalities, such as adding various greeting options in different languages, without deploying them in production builds. Moreover, build flags can help streamline testing by controlling which tests are executed, particularly beneficial for speeding up workflows when dealing with extensive end-to-end tests. This lesson highlights effective strategies for employing build flags alongside the init function to dynamically configure command registration in CLI applications, offering a powerful means to tailor application capabilities based on user requirements or testing scenarios.
In the last lesson, we took a look at using build tags in order to build different versions of our code depending on the operating system we were building for. As I mentioned in that lesson, build tags can be used for more than just writing cross-platform code.
You can actually use them to determine different features that should be shipped with your application, as well as a few other use cases that I find myself commonly using them for.
So in this lesson, we're going to take a look at how build flags work and some of the ways you can actually use them.
Example Project: Hello Application
To begin, here I have a really simple application called Hello, which demonstrates different ways to use build tags.
Basic Implementation
Project Structure:
hello/
├── main.go
└── go.mod
main.go (initial version):
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./hello <name>")
return
}
name := os.Args[1]
sayHello(name)
}
func sayHello(name string) {
fmt.Printf("Hello %s\n", name)
}
Testing the Basic Version:
go run main.go John
# Output: Hello John
Adding Experimental Features
Now let's say we have a new experimental feature that we want to include conditionally:
Experimental Random Greeting Feature:
package main
import (
"fmt"
"math/rand/v2"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./hello <name>")
return
}
name := os.Args[1]
sayHelloRandom(name) // New experimental function
}
// Experimental feature: Random multilingual greetings
func sayHelloRandom(name string) {
greetings := []string{
"Hello", // English
"Aloha", // Hawaiian
"Bonjour", // French
"Konnichiwa", // Japanese
}
// Randomly select a greeting
greeting := greetings[rand.IntN(len(greetings))]
fmt.Printf("%s %s\n", greeting, name)
}
Testing the Experimental Feature:
go run main.go John
# Output: Hello John (or Aloha John, Bonjour John, Konnichiwa John)
# Run multiple times to see different greetings
go run main.go John
# Output: Konnichiwa John
go run main.go John
# Output: Aloha John
go run main.go John
# Output: Bonjour John
The Problem
This experimental feature works, but we don't want it released for every build we produce. It's an experimental feature that we only want to give out for testing.
Question: How can we constrain this so that we don't package this function for every user?
Answer: Through build tags!
Method 1: File-Based Feature Separation
The first approach is similar to how we used build tags for cross-platform code - by constraining different files with the same function name.
Step 1: Create Separate Files
Project Structure:
hello/
├── main.go
├── hello.go # Default implementation
├── hello_random.go # Experimental implementation
└── go.mod
main.go:
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./hello <name>")
return
}
name := os.Args[1]
sayHello(name) // Implementation depends on build tags
}
// sayHello is implemented in separate files
hello.go (default implementation):
//go:build !random
package main
import "fmt"
func sayHello(name string) {
fmt.Printf("Hello %s\n", name)
}
hello_random.go (experimental implementation):
//go:build random
package main
import (
"fmt"
"math/rand/v2"
)
func sayHello(name string) {
greetings := []string{
"Hello", // English
"Aloha", // Hawaiian
"Bonjour", // French
"Konnichiwa", // Japanese
}
greeting := greetings[rand.IntN(len(greetings))]
fmt.Printf("%s %s\n", greeting, name)
}
Understanding the Build Tags:
//go:build !random
- Build this file when therandom
tag is NOT specified//go:build random
- Build this file when therandom
tag IS specified
Step 2: Testing Feature Separation
# Default build (uses hello.go)
go run . John
# Output: Hello John
# Experimental build (uses hello_random.go)
go run -tags random . John
# Output: Aloha John (or another random greeting)
# Build different versions
go build -o hello-stable .
go build -tags random -o hello-experimental .
# Test both versions
./hello-stable John
# Output: Hello John
./hello-experimental John
# Output: Konnichiwa John
Method 2: Additive Feature Registration (Preferred)
While the file separation approach works, I prefer using build tags as an additive measure - adding different features depending on whether you're passing them in.
The Command Registration Pattern
Let's look at a more sophisticated example using command registration:
Project Structure:
hello-register/
├── main.go
├── hello.go
├── hello_random.go
└── go.mod
main.go:
package main
import (
"fmt"
"os"
)
// Command registry
var commands = make(map[string]func())
// registerCommand adds a command to the registry
func registerCommand(name string, handler func()) {
commands[name] = handler
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Error: must pass in a command")
fmt.Println("Available commands:")
for name := range commands {
fmt.Printf(" - %s\n", name)
}
return
}
command := os.Args[1]
handler, exists := commands[command]
if !exists {
fmt.Printf("Unknown command: %s\n", command)
return
}
handler()
}
hello.go (always included):
//go:build hello
package main
import "fmt"
func init() {
fmt.Println("init1 called")
registerCommand("hello", sayHello)
}
func sayHello() {
fmt.Println("Hello!")
}
hello_random.go (conditionally included):
//go:build random
package main
import (
"fmt"
"math/rand/v2"
)
func init() {
fmt.Println("init2 called")
registerCommand("random", sayHelloRandom)
}
func sayHelloRandom() {
greetings := []string{
"Hello!",
"Aloha!",
"Bonjour!",
"Konnichiwa!",
}
greeting := greetings[rand.IntN(len(greetings))]
fmt.Println(greeting)
}
Understanding the init()
Function
The init()
function in Go is special:
- Called automatically when a package is imported
- Called before
main()
function executes - Multiple
init()
functions can exist in a package - Executed in order they're defined
- Perfect for registration patterns
Testing Command Registration
# Build with only hello command
go run -tags hello .
# Output:
# init1 called
# main called
# Error: must pass in a command
# Available commands:
# - hello
# Test hello command
go run -tags hello . hello
# Output:
# init1 called
# main called
# Hello!
# Build with only random command
go run -tags random .
# Output:
# init2 called
# main called
# Error: must pass in a command
# Available commands:
# - random
# Build with both commands
go run -tags "hello,random" .
# Output:
# init1 called
# init2 called
# main called
# Error: must pass in a command
# Available commands:
# - hello
# - random
# Test both commands
go run -tags "hello,random" . hello
# Output: Hello!
go run -tags "hello,random" . random
# Output: Konnichiwa! (or another random greeting)
Benefits of the Registration Pattern
- Additive composition - Features add to the application rather than replace
- Clean separation - Each feature is self-contained
- Easy maintenance - Add/remove features without touching main code
- Cobra compatibility - Works well with popular CLI frameworks
Practical Use Case: Testing Separation
One of the most common ways I use build tags is for separating different types of tests.
Example: Unit Tests vs End-to-End Tests
Project Structure:
myapp/
├── main.go
├── counter.go
├── counter_test.go # Unit tests (always run)
└── test/
└── e2e/
├── main_test.go # E2E tests (conditional)
└── api_test.go # E2E tests (conditional)
counter_test.go (unit tests):
package main
import "testing"
func TestCounter(t *testing.T) {
// Fast unit test
result := add(2, 3)
if result != 5 {
t.Errorf("Expected 5, got %d", result)
}
}
func add(a, b int) int {
return a + b
}
test/e2e/main_test.go (end-to-end tests):
//go:build e2e
package e2e
import (
"testing"
"time"
)
func TestE2EApplication(t *testing.T) {
// Slow end-to-end test
t.Log("Running slow E2E test...")
time.Sleep(2 * time.Second) // Simulate slow test
t.Log("E2E test completed")
}
test/e2e/api_test.go:
//go:build e2e
package e2e
import (
"testing"
"time"
)
func TestE2EAPI(t *testing.T) {
// Another slow E2E test
t.Log("Running API E2E test...")
time.Sleep(1 * time.Second) // Simulate slow test
t.Log("API E2E test completed")
}
Testing with Build Tags
# Run only unit tests (fast)
go test ./...
# Output:
# PASS
# ok myapp 0.002s
# Run only E2E tests
go test -tags e2e ./...
# Output:
# === RUN TestE2EApplication
# main_test.go:10: Running slow E2E test...
# main_test.go:12: E2E test completed
# === RUN TestE2EAPI
# api_test.go:10: Running API E2E test...
# api_test.go:12: API E2E test completed
# PASS
# ok myapp/test/e2e 3.003s
# Run all tests (unit + E2E)
go test -tags e2e ./...
Benefits of Test Separation
- Faster development - Run only unit tests during development
- CI/CD optimization - Different test suites for different stages
- Resource management - E2E tests might need databases, networks, etc.
- Parallel execution - Run different test types on different machines
Advanced Build Tag Patterns
Complex Boolean Logic
// Build only on Linux AND for testing
//go:build linux && testing
// Build on Linux OR macOS, but NOT Windows
//go:build (linux || darwin) && !windows
// Build for debugging OR development
//go:build debug || dev
// Build for production (not debug AND not dev)
//go:build !debug && !dev
Feature Flags
// feature_logging.go
//go:build logging
package main
import "log"
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
func debugLog(msg string) {
log.Printf("DEBUG: %s", msg)
}
// feature_logging_disabled.go
//go:build !logging
package main
func debugLog(msg string) {
// No-op when logging disabled
}
Performance Variants
// algo_fast.go
//go:build fast
package main
func processData(data []int) []int {
// Fast but memory-intensive algorithm
return fastProcess(data)
}
// algo_memory.go
//go:build !fast
package main
func processData(data []int) []int {
// Memory-efficient but slower algorithm
return memoryEfficientProcess(data)
}
Build Tag Best Practices
1. Use Descriptive Tag Names
// Good
//go:build debug
//go:build integration_tests
//go:build mysql_driver
// Avoid
//go:build a
//go:build temp
//go:build test1
2. Document Your Build Tags
// Package myapp provides CLI functionality.
//
// Build tags:
// - debug: Enable debug logging and extra validation
// - mysql: Include MySQL database driver
// - redis: Include Redis caching support
// - e2e: Include end-to-end tests
package myapp
3. Provide Fallbacks
// Always have a default implementation
//go:build !feature_x
func doSomething() {
// Default implementation
}
4. Use with CI/CD
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- run: go test ./...
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
- run: go test -tags e2e ./...
Common Use Cases Summary
Use Case | Example Tags | Benefits |
---|---|---|
Feature Flags | debug , beta , premium |
Conditional features, A/B testing |
Testing | unit , integration , e2e |
Faster development, resource control |
Drivers/Backends | mysql , postgres , redis |
Optional dependencies |
Performance | fast , memory_optimized |
Different algorithms for different needs |
Platforms | linux , windows , cloud |
Platform-specific code |
Environments | dev , staging , prod |
Environment-specific behavior |
Real-World Example: Complete CLI with Feature Flags
// main.go
package main
import (
"fmt"
"os"
)
var commands = make(map[string]func())
func registerCommand(name string, handler func()) {
commands[name] = handler
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Available commands:")
for name := range commands {
fmt.Printf(" - %s\n", name)
}
return
}
command := os.Args[1]
if handler, exists := commands[command]; exists {
handler()
} else {
fmt.Printf("Unknown command: %s\n", command)
}
}
// cmd_basic.go - Always included
package main
func init() {
registerCommand("version", showVersion)
registerCommand("help", showHelp)
}
func showVersion() {
fmt.Println("MyApp v1.0.0")
}
func showHelp() {
fmt.Println("MyApp - A demonstration CLI application")
}
// cmd_admin.go - Only for admin builds
//go:build admin
package main
import "fmt"
func init() {
registerCommand("admin", adminPanel)
registerCommand("debug", debugInfo)
}
func adminPanel() {
fmt.Println("Admin panel loaded")
}
func debugInfo() {
fmt.Println("Debug information displayed")
}
// cmd_experimental.go - Only for beta testing
//go:build beta
package main
import "fmt"
func init() {
registerCommand("experimental", experimentalFeature)
}
func experimentalFeature() {
fmt.Println("🧪 Experimental feature activated!")
}
Building Different Versions:
# Standard user version
go build -o myapp-standard .
# Admin version
go build -tags admin -o myapp-admin .
# Beta testing version
go build -tags "admin,beta" -o myapp-beta .
# Test different builds
./myapp-standard
# Available commands:
# - version
# - help
./myapp-admin
# Available commands:
# - version
# - help
# - admin
# - debug
./myapp-beta
# Available commands:
# - version
# - help
# - admin
# - debug
# - experimental
Summary
Build tags provide powerful control over what gets compiled into your Go applications:
- File-based separation - Different implementations in different files
- Additive features - Use
init()
functions to register features conditionally - Test separation - Run different test suites based on needs
- Feature flags - Enable/disable functionality for different builds
- Performance variants - Different algorithms for different requirements
The registration pattern with init()
functions is particularly powerful for CLI applications and works excellently with frameworks like Cobra.
As you can see, by using this approach, you can constrain some of the actions that are going to take place when it comes to your Go code, either constraining the features you want to compile, or controlling the different tests that will be run when you test your entire project.
This is perhaps the most common way that I use build tags in my own Go projects.
I've left links to additional resources on build tags and the init()
function in the lesson resources if you're interested in using them more in your own applications.
In the next lesson, we're going to be digging into regular expressions, which are a really common approach to performing string manipulation and string searching when it comes to both Go and other programming languages.
No homework tasks for this lesson.