Nested Assignments

We finished the previous post with a riddle, so let’s start this one with another. What does the following code print?

	var (
		a, b = 1, 2
		c    = &a
	)

	c, *c = func() (*int, int) { c, a = &b, 3; a := b; return nil, a }()

	fmt.Println(a, b)

Try it on the Go Playground.

While this would be a good entry into an obfuscated Go code contest, it demonstrates that nested assignments can become really tricky.

What Is a Nested Assignment?

Nested assignments are rare, but happen in popular projects. Go’s database/sql.DB has an internal retry function that works essentially like this:

func retry(f func() error) error {
	const maxTries = 10

	var errs []error

	for range maxTries {
		err := f()
		if err == nil {
			return nil
		}
		errs = append(errs, err)
	}

	return errors.Join(errs...)
}

It takes a function and retries it until it succeeds or exceeds the retry count. This is useful, but since it uses no generics or reflection, you’ll have to return any result via a capture.

To simulate some work we use a function that generates a random number and returns an error when it’s odd:

var errOdd = errors.New("odd number")

func randomEven() (int, error) {
	v := rand.IntN(10)

	if v&1 == 1 {
		return v, errOdd
	}

	return v, nil
}

So with retry we could write:

func evenNumbers1() (n1, n2 int) {
	if err := retry(func() error {
		result, err := randomEven()
		time.Sleep(1 * time.Microsecond)

		if err != nil {
			return err
		}

		n1 = result

		return nil
	}); err != nil {
		return -1, -1
	}

	if err := retry(func() error {
		result, err := randomEven()
		time.Sleep(1 * time.Microsecond)

		if err != nil {
			return err
		}

		n2 = result

		return nil
	}); err != nil {
		return -1, -1
	}

	return n1, n2
}

This generates two even numbers and returns them, or -1, -1 when it fails.

However, some might want to write:

func evenNumbers2() (n1, n2 int) {
	var err error

	err = retry(func() error {
		n1, err = randomEven()
		time.Sleep(1 * time.Microsecond)

		return err
	})
	if err != nil {
		return -1, -1
	}

	err = retry(func() error {
		n2, err = randomEven()
		time.Sleep(1 * time.Microsecond)

		return err
	})
	if err != nil {
		return -1, -1
	}

	return n1, n2
}

What we now have is a nested assignment: Inside the err = retry(...) assignment is another assignment to err: n1, err = randomEven().

This looks a little shorter, especially in short functions.

Refactoring

Since it takes some time (a microsecond in our example, but it could be longer in practice), we might want to parallelize the operations with errgroup.Group:

func evenNumbers1p() (n1, n2 int) {
	var g errgroup.Group

	g.Go(func() error {
		return retry(func() error {
			result, err := randomEven()
			time.Sleep(1 * time.Microsecond)

			if err != nil {
				return err
			}

			n1 = result

			return nil
		})
	})

	g.Go(func() error {
		return retry(func() error {
			result, err := randomEven()
			time.Sleep(1 * time.Microsecond)

			if err != nil {
				return err
			}

			n2 = result

			return nil
		})
	})

	if err := g.Wait(); err != nil {
		return -1, -1
	}

	return n1, n2
}

And it prints even numbers.

Let’s see what happens when we parallelize the second approach (evenNumbers2). We do the same modifications as above:

func evenNumbers2p() (n1, n2 int) {
	var err error

	var g errgroup.Group

	g.Go(func() error {
		return retry(func() error {
			n1, err = randomEven()

			time.Sleep(1 * time.Microsecond)

			return err
		})
	})

	g.Go(func() error {
		return retry(func() error {
			n2, err = randomEven()

			time.Sleep(1 * time.Microsecond)

			return err
		})
	})

	if err := g.Wait(); err != nil {
		return -1, -1
	}

	return n1, n2
}

It’s mostly a copy-and-paste job, but surprisingly we now have odd numbers in the mix.

When we run this code with go run -race we get WARNING: DATA RACE, because both functions capture and use the same err variable, leading to unsynchronized writes.

This is bad style. Although it works in simple cases and can be refactored with care, you risk wasting time debugging issues like this.

When you prefer the shorter version, you could shadow err explicitly (full code on the Go Playground):

	err = retry(func() error {
		var err error
		n1, err = randomEven()
		time.Sleep(1 * time.Microsecond)

		return err
	})

This is still somewhat tricky, since the distinction between captured and local variables is not immediately obvious. The first example (evenNumbers1) makes the assignment to the captured variable (n1 = result) very explicit, avoiding this ambiguity.

When you want to modify captured variables, the best approach is to do this explicitly and not try to use some short, sneaky construct. And especially avoid modifying the variable you are assigning to, even when the total construct is fine during runtime.

These examples demonstrate why nested assignments, while technically valid, create readability and maintenance issues.

Here’s a more readable (but still confusing) variation of the opening riddle (Go Playground):

	var f func() int

	f = func() int { g := f; f = func() int { f = g; return 2 }; return 1 }

	for range 5 {
		fmt.Println(f())
	}

This alternates between printing 1 and 2 because the function reassigns itself on each call.

Summary

Modifying the variable you are assigning to (a nested assignment) is hard to read and error-prone during refactoring.

You should shadow variables when you don’t plan to use the value of the outer variable again. Use err for short-lived error values. Choose a distinct name for variables you need to access across multiple operations to avoid shadowing them — for instance, an error that you plan to process after calling several functions.

scopeguard warns you about these issues. Try it.