Back

5 Tips To Write Better Go Functions

You'll learn 5 tips and tricks to help you write better Go functions.

In this video, you'll learn 5 tips and tricks to help you write better Go functions. These patterns will make your code easier to read, test, and maintain. For each tip, we'll start with a bad example and then show you how to fix it.

Let's say you're building a social media app. Users need to upload photos that get validated, resized, watermarked, and stored.

1. Return Early to Reduce Nesting

Deep nesting makes code hard to read. Early returns flatten this structure by handling error cases first, then letting the happy path flow at the base indentation level.

Bad - deeply nested:

func UploadPhoto(ps *PhotoService, file *File) error {
    if ps.IsSupportedFormat(file) {
        if file.Size <= ps.MaxFileSize {
            img, err := ps.DecodeImage(file)
            if err == nil {
                result, err := ps.PrepareForUpload(img)
                if err == nil {
                    return ps.Upload(result)
                } else {
                    return err
                }
            } else {
                return err
            }
        } else {
            return errors.New("file too large")
        }
    } else {
        return errors.New("unsupported format")
    }
}

The actual work - DecodeImage, PrepareForUpload, and Upload - is buried four levels deep. Each new validation adds another nesting level, pushing the happy path further right.

Good - early returns:

func UploadPhoto(ps *PhotoService, file *File) error {
    if !ps.IsSupportedFormat(file) {
        return errors.New("unsupported format")
    }
    if file.Size > ps.MaxFileSize {
        return errors.New("file too large")
    }

    img, err := ps.DecodeImage(file)
    if err != nil {
        return err
    }

    result, err := ps.PrepareForUpload(img)
    if err != nil {
        return err
    }

    return ps.Upload(result)
}

Each check happens at the top level. When a check fails, we return immediately. The happy path flows naturally at the end.

2. Keep Functions Small and Focused

A function should do one thing well. If you're using "and" to describe what it does, that's a signal it should be split.

Bad - function doing too much:

func (s *PhotoService) PrepareForUpload(img image.Image) (*ImageResult, error) {
    // Resize
    if img.Bounds().Dx() > 1920 {
        img = resize.Resize(1920, 0, img, resize.Lanczos3)
    }

    // Compress
    var buf bytes.Buffer
    jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})

    // Generate thumbnail
    thumb := resize.Resize(200, 0, img, resize.Lanczos3)
    buf.Reset()
    jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80})
    thumbData := buf.Bytes()

    // Add watermark
    watermarked := imaging.Overlay(img, s.watermarkImg, image.Pt(10, 10), 0.5)
    buf.Reset()
    jpeg.Encode(&buf, watermarked, &jpeg.Options{Quality: 90})

    return &ImageResult{
        Full:      buf.Bytes(),
        Thumbnail: thumbData,
    }, nil
}

This function resizes, compresses, generates thumbnails, and adds watermarks. Want to change thumbnail size? You're editing a function that also handles watermark positioning.

Good - split into focused functions:

func (s *PhotoService) PrepareForUpload(img image.Image) (*ImageResult, error) {
    img = s.Resize(img)
    thumb := s.CreateThumbnail(img)
    watermarked := s.AddWatermark(img)

    return &ImageResult{
        Full:      s.Compress(watermarked),
        Thumbnail: s.Compress(thumb),
    }, nil
}

func (s *PhotoService) Resize(img image.Image) image.Image {
    if img.Bounds().Dx() <= s.cfg.MaxWidth {
        return img
    }
    return resize.Resize(uint(s.cfg.MaxWidth), 0, img, resize.Lanczos3)
}

func (s *PhotoService) CreateThumbnail(img image.Image) image.Image {
    return resize.Resize(uint(s.cfg.ThumbnailSize), 0, img, resize.Lanczos3)
}

func (s *PhotoService) AddWatermark(img image.Image) image.Image {
    return imaging.Overlay(img, s.cfg.WatermarkImg, image.Pt(10, 10), 0.5)
}

func (s *PhotoService) Compress(img image.Image) []byte {
    var buf bytes.Buffer
    jpeg.Encode(&buf, img, &jpeg.Options{Quality: s.cfg.Quality})
    return buf.Bytes()
}

Each function has one job. You can test watermarking without compression. When thumbnail size changes, you only touch CreateThumbnail. The main function reads like a summary of what happens.

3. Use Descriptive Names That Reveal Intent

A function's name should tell you what it does without reading the implementation. Go favors clarity over brevity.

Bad - vague names:

func (s *PhotoService) updateImage(img image.Image) image.Image {
    if img.Bounds().Dx() <= s.cfg.MaxWidth {
        return img
    }
    return resize.Resize(uint(s.cfg.MaxWidth), 0, img, resize.Lanczos3)
}

func (s *PhotoService) checkFile(f *File) bool {
    ext := strings.ToLower(filepath.Ext(f.Name))
    return ext == ".jpg" || ext == ".png" || ext == ".gif"
}

What does updateImage do - resize, compress, convert? What does checkFile check - size, format, permissions?

Good - names reveal intent:

func (s *PhotoService) ResizeToMaxWidth(img image.Image) image.Image {
    if img.Bounds().Dx() <= s.cfg.MaxWidth {
        return img
    }
    return resize.Resize(uint(s.cfg.MaxWidth), 0, img, resize.Lanczos3)
}

func (s *PhotoService) IsSupportedFormat(f *File) bool {
    ext := strings.ToLower(filepath.Ext(f.Name))
    return ext == ".jpg" || ext == ".png" || ext == ".gif"
}

Now the call site is clear: ps.ResizeToMaxWidth(img) and if ps.IsSupportedFormat(file). Use Is prefix for boolean returns.

Go naming conventions:

// Getters omit "Get"
func (img *Image) Width() int { return img.width }
func (img *Image) Height() int { return img.height }

// Booleans use Is, Has, Can
func (img *Image) IsTransparent() bool { return img.hasAlpha }
func (img *Image) HasMetadata() bool { return img.meta != nil }

// Constructors use New
func NewImageProcessor(cfg Config) *ImageProcessor {
    return &ImageProcessor{cfg: cfg}
}

4. Minimize Function Parameters

Functions with long parameter lists are hard to call correctly. You need to remember the order, what each parameter means, and which ones are optional. Three or four parameters is usually the upper limit before things get unwieldy.

Bad - too many parameters:

func (s *PhotoService) PrepareForUpload(img image.Image, maxWidth int,
    maxHeight int, quality int, format string, addWatermark bool,
    watermarkPath string, generateThumb bool, thumbWidth int) (*ImageResult, error) {
    // Implementation
}

result, err := ps.PrepareForUpload(img, 1920, 1080, 85, "jpeg", true,
    "/path/to/watermark.png", true, 200)

Is that true for watermark or thumbnail? What's 200 - width or quality?

Good - configuration struct:

type PhotoConfig struct {
    MaxWidth      int
    MaxHeight     int
    Quality       int
    Format        string
    WatermarkImg  image.Image
    ThumbnailSize int
}

func (s *PhotoService) PrepareForUpload(img image.Image, cfg PhotoConfig) (*ImageResult, error) {
    // Implementation uses cfg.MaxWidth, cfg.Quality, etc.
}

result, err := ps.PrepareForUpload(img, PhotoConfig{
    MaxWidth:      1920,
    MaxHeight:     1080,
    Quality:       85,
    Format:        "jpeg",
    WatermarkImg:  watermarkImg,
    ThumbnailSize: 200,
})

Each config field is named, so you can't mix up the order. The call site documents itself - you can see exactly what each value means.

5. Make Dependencies Explicit

Functions that secretly depend on global variables or hidden state are hard to test. When dependencies are explicit - passed as parameters - your functions become testable and their requirements become obvious.

Bad - hidden global dependency:

var storage *S3Client

func SaveImage(userID string, data []byte) (string, error) {
    path := fmt.Sprintf("images/%s/%s.jpg", userID, uuid.New().String())
    err := storage.Upload(path, data)
    return path, err
}

How do you test SaveImage without uploading to real S3? You can't - it depends on the global storage. Testing with real S3 is slow, costs money, requires network access, and can fail for reasons unrelated to your code.

Good - explicit dependency:

type Storage interface {
    Upload(path string, data []byte) error
}

type PhotoService struct {
    storage Storage
}

func (s *PhotoService) SavePhoto(userID string, data []byte) (string, error) {
    path := fmt.Sprintf("photos/%s/%s.jpg", userID, uuid.New().String())
    err := s.storage.Upload(path, data)
    return path, err
}

// In production, inject real S3:
service := &PhotoService{storage: s3Client}

// For testing, inject a fake:
type FakeStorage struct{}

func (f *FakeStorage) Upload(path string, data []byte) error {
    return nil
}

func TestSavePhoto(t *testing.T) {
    service := &PhotoService{storage: &FakeStorage{}}

    path, err := service.SavePhoto("user-123", []byte("photo data"))
    if err != nil {
        t.Fatal(err)
    }

    if !strings.Contains(path, "user-123") {
        t.Errorf("expected path to contain user ID, got %s", path)
    }
}

The PhotoService takes storage as a struct field. In production, inject real S3. In tests, inject a fake. No globals needed.

Writing Functions That Last

These five tips - returning early, keeping functions focused, using descriptive names, minimizing parameters, and making dependencies explicit - form the foundation of well-designed Go functions. Apply them consistently and your code becomes easier to read, test, and maintain.