Question

Pairs and Tuples in Go: Structs, Multiple Returns, and Channels

gotuples

Question

I need a queue that stores (string, int) pairs in Go. A named struct works:

type job struct {
    url   string
    depth int
}

queue := make(chan job)
queue <- job{url: url, depth: depth}

Does Go provide a built-in pair or tuple data type? Go functions can return multiple values, but are those values first-class tuple values in Go's type system? I also tried Python-like syntax, but it does not compile:

queue := make(chan (string, int))

What is the idiomatic Go approach for representing and passing a pair of values?

Short Answer

Go does not have a built-in, first-class pair or tuple type. The idiomatic way to group related values is a struct, usually a named struct with meaningful field names. Go's multiple return values are useful for function calls, but they cannot be stored, passed around, or used as a channel element as one tuple value.

Concept

Go deliberately keeps its type system simple: a value has one type, such as int, string, []byte, or a struct type. When you need one value that contains several related pieces of data, define a struct.

type job struct {
    url   string
    depth int
}

A job value now combines a URL and its crawl depth into one typed value. It can be assigned to variables, placed in slices, sent through channels, used as a map value, and passed to functions.

Go functions may return multiple values:

func splitURL(raw string) (string, int) {
    return raw, len(raw)
}

However, (string, int) is not a tuple type that you can name or store. It is a function signature result list. The returned values must be received in a supported context, commonly with assignment:

url, length := splitURL("https://example.com")

This distinction matters because a channel carries one value per send. If that value has several parts, package those parts into a struct.

Mental Model

Think of a Go function with multiple return values as a clerk handing you two separate items across a counter. You must receive them into separate variables:

name, age := getPerson()

A struct is a labelled container that holds both items together. Once the items are in the container, you can put the container on a queue, save it in a slice, or hand it to another function:

person := Person{Name: "Ava", Age: 30}

For queue work, a job struct is the container and a chan job is the conveyor belt carrying those containers.

Take Quiz

Syntax and Examples

A named struct is the usual choice when the values describe one meaningful thing.

package main

import "fmt"

type Job struct {
    URL   string
    Depth int
}

func main() {
    queue := make(chan Job, 2)

    queue <- Job{URL: "https://example.com", Depth: 0}
    queue <- Job{URL: "https://go.dev", Depth: 1}

    first := <-queue
    fmt.Println(first.URL, first.Depth)
}

Job is a new type with two fields:

  • URL stores the string.
  • Depth stores the integer.
  • chan Job is a channel whose elements are Job values.
  • Job{URL: ..., Depth: ...} is a struct literal using field names.

Named fields in a literal are usually clearer and safer than positional fields:

// Valid, but easier to break if field order changes.
job := Job{"https://example.com", 0}

// Preferred for readability.
job = Job{URL: "https://example.com", Depth: 0}

You can also use an anonymous struct for a small, local case:

queue := make(chan struct {
    url   string
    depth int
})

But this becomes repetitive and less reusable. Prefer a named job or Job type when the data has a meaningful role.

Step by Step Execution

Consider this buffered work queue:

type job struct {
    url   string
    depth int
}

func main() {
    queue := make(chan job, 1)

    next := job{url: "https://example.com", depth: 2}
    queue <- next

    received := <-queue
    fmt.Println(received.url)
    fmt.Println(received.depth)
}

Execution flow:

  1. type job struct defines a type that groups url and depth.
  2. make(chan job, 1) creates a channel that can hold one job before a receiver is ready.
  3. next is created as one job value containing both fields.
  4. queue <- next sends that one job value into the channel.
  5. received := <-queue receives a job value from the channel.
  6. received.url evaluates to "https://example.com".
  7. received.depth evaluates to 2.

The channel never sends two unrelated values. It sends one value whose type is job.

Real World Use Cases

Structs are used for pair-like data throughout Go programs:

  • Web crawlers: a URL and crawl depth, such as Job{URL: link, Depth: depth + 1}.
  • Background workers: a task identifier and retry count.
  • API processing: a request payload and metadata such as a correlation ID.
  • File processing: a file path and its processing status.
  • Caching: a value and expiration time.
  • Database work: a record ID and a pending operation.

Meaningful names are especially valuable in these cases. RetryCount communicates more than “the second int in a pair.”

Take Quiz

Real Codebase Usage

In real Go code, developers generally define small domain types rather than use generic pairs.

Queue a unit of work

type CrawlJob struct {
    URL   string
    Depth int
}

func enqueue(queue chan<- CrawlJob, url string, depth int) {
    queue <- CrawlJob{URL: url, Depth: depth}
}

The chan<- CrawlJob parameter means this function can only send jobs, making its intent explicit.

Return multiple values for a short-lived result

Multiple returns are commonly used for a primary result and an error:

page, err := fetch(url)
if err != nil {
    return err
}

They are also useful for map lookups:

score, found := scores[userID]
if !found {
    score = 0
}

Here, the values are consumed immediately. If the result needs to travel through a queue or be retained as a single unit, define a struct instead.

Add validation with a guard clause

func process(job CrawlJob) error {
    if job.URL == "" {
        return errors.New("job URL is required")
    }
    if job.Depth < 0 {
        return errors.New("job depth cannot be negative")
    }

    // Process a valid job.
    return nil
}

A struct provides a clear place to validate fields before work begins.

Common Mistakes

Trying Python tuple syntax

This does not compile in Go:

queue := make(chan (string, int))

Go does not define (string, int) as a standalone tuple type. Use a struct:

queue := make(chan job)

Treating multiple return values as one assignable value

This does not compile:

func values() (string, int) {
    return "item", 3
}

x := values() // Error: multiple-value values() in single-value context

Receive each result separately:

name, count := values()

Or package the results in a struct when one value is required.

Sending fields separately through separate channels

urls := make(chan string)
depths := make(chan int)

This can cause coordination problems: a URL may accidentally be matched with the wrong depth, especially with multiple goroutines. Send one job struct instead.

Using positional literals when field order is unclear

j := job{"https://example.com", 2}

This is valid, but named fields are easier to read:

j := job{url: "https://example.com", depth: 2}

Exporting or hiding fields unintentionally

Lowercase identifiers are package-private:

type job struct {
    url string
}

Use uppercase names if another package must create or read the values:

type Job struct {
    URL string
}

Comparisons

ApproachCan represent related values as one value?Can be sent through chan?Best use
Named structYesYesMost application data and work items
Anonymous structYesYesSmall local, one-off data shapes
Multiple return valuesNoNot directlyReturning a result plus an error or status
Slice/arrayYesYesA sequence of same-typed values
MapYesYesLookup data keyed by another value

Structs versus multiple return values

// Multiple returns: consume immediately.
func parsePort(s string) (int, error) {
    // ...
    return 8080, nil
}

// Struct: one value that can be stored or queued.
type Server struct {
    Host string
    Port int
}

Use multiple returns when values are naturally the immediate outcome of a function call. Use a struct when the values together represent an object, message, configuration item, or unit of work.

Take Quiz

Cheat Sheet

// Define a pair-like type.
type Job struct {
    URL   string
    Depth int
}

// Create a channel of jobs.
queue := make(chan Job)

// Send one struct value.
queue <- Job{URL: "https://example.com", Depth: 0}

// Receive one struct value.
job := <-queue
fmt.Println(job.URL, job.Depth)

// Buffered channel.
queue = make(chan Job, 10)

// Function returning multiple separate values.
func lookup(id int) (string, bool) {
    return "found", true
}

name, ok := lookup(1)

Rules to remember:

  • Go has no built-in generic pair or tuple type.
  • (string, int) is valid in a function result signature, not as a general value type.
  • A channel sends exactly one value of its element type at a time.
  • Use a struct to group values that belong together.
  • Prefer descriptive field names and named struct literals.
  • Use exported type and field names (Job, URL) only when other packages need access.

FAQ

Does Go have a built-in tuple type?

No. Go has multiple return values, but it does not have a first-class tuple type that can be stored in a variable, slice, map, or channel.

How do I make a pair in Go?

Define a struct with two fields:

type Pair struct {
    First  string
    Second int
}

In application code, prefer names that describe the data, such as URL and Depth.

Can I send two values through one Go channel?

Not as two separate channel elements in one send operation. Put both values in a struct and send that struct.

Why does make(chan (string, int)) fail?

Because (string, int) is not a Go type expression for a tuple. Parenthesized type lists are supported in specific syntax such as function parameters and results, not as a general data type.

Can a function's multiple return values be assigned to one variable?

Usually no. Multiple results must be received by a matching list of variables, except in special language contexts. If you need one storable value, return a struct.

Should I use an anonymous struct for a pair?

It is valid for short, local code, but a named struct is usually more readable, reusable, and easier to document.

Are structs expensive to send over channels?

Small structs are commonly sent by value. If the object is large or shared mutable state is required, a channel of pointers may be appropriate, but use pointers only when they solve a real need.

Take Quiz

Mini Project

Description

Build a small crawl-job queue. Each queued item contains a URL and the depth at which it was discovered. This demonstrates why related values should travel through a channel as one struct value.

Goal

Create, enqueue, and process URL/depth jobs using a typed Go channel.

Requirements

Use a Job struct with URL and Depth fields. Create a buffered channel that stores Job values. Enqueue at least three jobs. Receive and print every job with its URL and depth. Close the channel after all jobs have been added.

Take Quiz

Keep learning

Related questions

View more