
Go Best Practices
- 1 installs
- 2 repo stars
- Updated February 21, 2026
- ofershap/go-best-practices
Provides modern Go patterns for error wrapping, structured logging with slog, context handling, concurrency, and Go 1.22+ features.
About
This skill teaches current Go best practices covering error wrapping with %w, slog logging, context, goroutine lifecycle, errgroup, and table-driven tests. A developer applies it when writing or reviewing Go code to avoid outdated patterns.
- 12 critical rules plus functional-options and repository patterns
- Covers Go 1.22+ range-over-int and loop-variable changes
Go Best Practices by the numbers
- 1 all-time installs (skills.sh)
- Ranked #79 of 98 Go skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ofershap/go-best-practices --skill go-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 2 |
| Last updated | February 21, 2026 |
| Repository | ofershap/go-best-practices ↗ |
What it does
Provides modern Go patterns for error wrapping, structured logging with slog, context handling, concurrency, and Go 1.22+ features.
Files
When to use
Use this skill when working with Go code. It teaches you the current best practices and prevents common mistakes that AI agents make with outdated patterns.
Critical Rules
1. Always wrap errors with context using fmt.Errorf and %w
Wrong:
func createUser(name string) error {
user, err := db.Insert(name)
if err != nil {
return err
}
return nil
}Correct:
func createUser(name string) error {
user, err := db.Insert(name)
if err != nil {
return fmt.Errorf("creating user %s: %w", name, err)
}
return nil
}Why: Error chains need context at each level for debugging; unwrapped errors lose origin context.
2. Use log/slog for structured logging instead of log.Printf
Wrong:
log.Printf("user %s created with id %d", name, id)Correct:
slog.Info("user created", "name", name, "id", id)Why: slog is in the standard library since Go 1.21, produces structured (JSON/text) output, supports levels.
3. Pass context.Context as first parameter, never store in structs
Wrong:
type Service struct {
ctx context.Context
}
func (s *Service) Do() error {
return s.db.Query(s.ctx, "...")
}Correct:
type Service struct {
db *sql.DB
}
func (s *Service) Do(ctx context.Context) error {
return s.db.QueryContext(ctx, "...")
}Why: Context has a lifecycle tied to the request, not the service; storing it conflates lifecycles.
4. Prevent goroutine leaks — always ensure goroutines can exit
Wrong:
go func() {
for {
processItem(<-ch)
}
}()Correct:
go func() {
for {
select {
case item, ok := <-ch:
if !ok {
return
}
processItem(item)
case <-ctx.Done():
return
}
}
}()Why: Leaked goroutines consume memory (min 2KB stack each) and grow unboundedly.
5. Use errors.Is and errors.As for error checking, not == or type assertion
Wrong:
if err == sql.ErrNoRows {
return nil, nil
}Correct:
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}Why: errors.Is traverses the wrapped error chain; == only matches the outermost error.
6. Use range over integers (Go 1.22+) instead of C-style for loops
Wrong:
for i := 0; i < 10; i++ {
process(i)
}Correct:
for i := range 10 {
process(i)
}Why: Cleaner, less error-prone, idiomatic since Go 1.22.
7. Use table-driven tests with t.Run subtests
Wrong:
func TestAdd_Positive(t *testing.T) { ... }
func TestAdd_Negative(t *testing.T) { ... }
func TestAdd_Zero(t *testing.T) { ... }Correct:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
{"zero", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}Why: DRY, easy to add cases, subtests run independently and can be filtered.
8. Return concrete types from constructors, accept interfaces
Wrong:
func NewService() ServiceInterface {
return &service{}
}Correct:
func NewService() *Service {
return &Service{}
}Why: Accept interfaces, return structs — keeps packages decoupled, lets consumers define interfaces they need.
9. Use errgroup for coordinated goroutine lifecycle
Wrong:
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
if err := t.Run(); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(task)
}
wg.Wait()Correct:
g, ctx := errgroup.WithContext(ctx)
for _, task := range tasks {
task := task
g.Go(func() error {
return task.Run(ctx)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("task failed: %w", err)
}Why: errgroup handles sync, error collection, and context cancellation in one.
10. Avoid init() functions — use explicit initialization
Wrong:
var db *sql.DB
func init() {
db = connectDB()
}Correct:
func NewApp() (*App, error) {
db, err := connectDB()
if err != nil {
return nil, fmt.Errorf("connect db: %w", err)
}
return &App{db: db}, nil
}Why: init() creates hidden dependencies, makes testing difficult, order is unpredictable across packages.
11. Handle the loop variable capture fix (Go 1.22+)
Wrong (pre-1.22 pattern, unnecessary in 1.22+):
for _, item := range items {
item := item
go func() {
process(item)
}()
}Correct (Go 1.22+):
for _, item := range items {
go func() {
process(item)
}()
}Why: Go 1.22+ fixed loop variable capture; loop variables are per-iteration. Remove unnecessary re-declarations.
12. Use proper struct validation, not manual checks
Wrong:
func CreateUser(req *CreateUserRequest) error {
if req.Name == "" {
return errors.New("name required")
}
if len(req.Name) < 2 {
return errors.New("name too short")
}
if req.Email == "" {
return errors.New("email required")
}
// ...
}Correct:
type CreateUserRequest struct {
Name string `validate:"required,min=2"`
Email string `validate:"required,email"`
}
func CreateUser(req *CreateUserRequest) error {
if err := validator.Validate(req); err != nil {
return fmt.Errorf("validation: %w", err)
}
// ...
}Why: Declarative, consistent, handles complex validation rules.
Patterns
Functional options pattern for configurable constructors
type Server struct {
host string
port int
}
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) { s.host = host }
}
func WithPort(port int) Option {
return func(s *Server) { s.port = port }
}
func NewServer(opts ...Option) *Server {
s := &Server{host: "localhost", port: 8080}
for _, opt := range opts {
opt(s)
}
return s
}Repository pattern with interface
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) error
}
type userRepo struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *userRepo {
return &userRepo{db: db}
}
func (r *userRepo) GetByID(ctx context.Context, id int64) (*User, error) {
// ...
}Middleware chain pattern
type Middleware func(http.Handler) http.Handler
func chain(middlewares ...Middleware) Middleware {
return func(final http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
final = middlewares[i](final)
}
return final
}
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slog.Info("request", "method", r.Method, "path", r.URL.Path)
next.ServeHTTP(w, r)
})
}Graceful shutdown with signal handling
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown failed", "err", err)
}
}Anti-Patterns
- Do not use panic for expected errors — only for programmer bugs (nil dereference, out of
bounds). Return errors for recoverable failures.
- Do not ignore errors with `_ = someFunc()` — handle or explicitly document why ignoring is
acceptable.
- Do not use global variables for dependency injection — pass dependencies via constructors or
function parameters.
- Do not use naked goroutines without lifecycle management — ensure every goroutine has an exit
path (context cancellation, done channel, or bounded loop).
- Do not return interfaces from packages — return concrete types; let the consumer define the
interface they need.