Context WithTimeout vs WithDeadline in Go Compared
Compare context.WithTimeout and WithDeadline in Go: the same mechanism, how to choose, how deadlines nest, and how to share one budget across calls.

The two constructors look different and are the same. context.WithTimeout takes a duration and context.WithDeadline takes a time.Time, and the standard library implements the first as a one-line call to the second. Which one you write is a question about what you know when you write it: a length of time, or a moment. The difference shows up in how a budget is shared across steps, so this article compares them on the cases where it matters.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It follows context explained and belongs to the concurrency material. Everything ran on Go 1.25.6.
They are one mechanism
package main
import (
"context"
"fmt"
"time"
)
func main() {
parent := context.Background()
a, cancelA := context.WithTimeout(parent, 200*time.Millisecond)
defer cancelA()
b, cancelB := context.WithDeadline(parent, time.Now().Add(200*time.Millisecond))
defer cancelB()
da, _ := a.Deadline()
db, _ := b.Deadline()
fmt.Println("deadlines within a millisecond of each other:", db.Sub(da).Abs() < time.Millisecond)
<-a.Done()
<-b.Done()
fmt.Println(a.Err(), "|", b.Err())
}deadlines within a millisecond of each other: true
context deadline exceeded | context deadline exceededBoth contexts expire about 200 ms after creation and report context deadline exceeded. WithTimeout(parent, d) is defined as WithDeadline(parent, time.Now().Add(d)), so there is no behavioral difference between them beyond when time.Now() is read. The time package's monotonic clock is used for the comparison, so changes to the wall clock (NTP corrections, daylight saving) do not make a deadline fire early or late.
When to prefer each
Use WithTimeout for a limit on a single operation measured from now: "this HTTP call may take 2 seconds", "this query may run for 500 ms". The duration is the natural unit, the code is shorter, and each call gets a fresh budget.
Use WithDeadline when the end of the budget is an instant that already exists:
- A deadline arrived from the caller (a gRPC deadline, a header with an absolute time), and you pass it along.
- You compute one deadline for a multi-step operation, and every step shares it.
- You want a retry loop to stop at a fixed time and not to restart a timeout on each attempt.
The retry loop is where the choice causes bugs. Consider three attempts, each with a 1-second timeout, for an operation that should take at most 2 seconds in total. If every attempt does WithTimeout(ctx, time.Second) the worst case is three seconds, and the operation overruns its budget. If the operation derives one deadline and every attempt shares it, the total is bounded:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func attempt(ctx context.Context) error {
select {
case <-time.After(60 * time.Millisecond): // every attempt takes 60ms and fails
return errors.New("transient failure")
case <-ctx.Done():
return ctx.Err()
}
}
func perAttemptTimeouts() (int, time.Duration) {
start := time.Now()
n := 0
for i := 0; i < 5; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
n++
err := attempt(ctx)
cancel()
if err == nil {
break
}
}
return n, time.Since(start)
}
func sharedDeadline() (int, time.Duration) {
start := time.Now()
ctx, cancel := context.WithDeadline(context.Background(), start.Add(200*time.Millisecond))
defer cancel()
n := 0
for i := 0; i < 5; i++ {
n++
if err := attempt(ctx); err == nil {
break
} else if ctx.Err() != nil {
break
}
}
return n, time.Since(start)
}
func main() {
n, d := perAttemptTimeouts()
fmt.Printf("per-attempt timeouts: %d attempts, about %v\n", n, d.Round(50*time.Millisecond))
n, d = sharedDeadline()
fmt.Printf("shared deadline: %d attempts, about %v\n", n, d.Round(50*time.Millisecond))
}per-attempt timeouts: 5 attempts, about 300ms
shared deadline: 4 attempts, about 200msWith per-attempt timeouts that never expire (each attempt fails in 60 ms, under its 100 ms limit), all five attempts run, about 300 ms in total, regardless of any overall budget. The shared deadline of 200 ms stops the loop after four attempts (the fourth is cut off partway), at the fixed instant, about 200 ms in total. This is the practical meaning of "an absolute deadline": it is a budget for the whole operation. Production retry loops usually need both: a shared deadline for the whole thing and a per-attempt timeout inside it (retry with backoff).
Deadlines only get shorter
A context's effective deadline is the earliest deadline along its chain. A child may set a longer deadline, and it will have no effect, because the parent's cancellation propagates down:
package main
import (
"context"
"fmt"
"time"
)
func main() {
parent, cancelP := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancelP()
child, cancelC := context.WithTimeout(parent, 10*time.Second)
defer cancelC()
start := time.Now()
<-child.Done()
fmt.Println("child ended after about", time.Since(start).Round(50*time.Millisecond), "not 10s:", child.Err())
cd, _ := child.Deadline()
pd, _ := parent.Deadline()
fmt.Println("child reports the parent's deadline:", cd.Equal(pd))
}child ended after about 50ms not 10s: context deadline exceeded
child reports the parent's deadline: trueThe child asks for ten seconds and gets fifty milliseconds. When a child is asked for a deadline later than its parent's, WithDeadline returns a context that inherits the parent's deadline, and Deadline() reports the earlier one. This is the property that makes a deadline set at the edge of a service safe: no inner layer can accidentally extend it, however generous its own timeouts are.
Using the remaining budget
ctx.Deadline() tells you when the context will expire, which makes budget arithmetic possible:
package main
import (
"context"
"fmt"
"time"
)
// callWithShare gives one of n remaining calls an equal share of the time left.
func callWithShare(ctx context.Context, remainingCalls int) (context.Context, context.CancelFunc) {
dl, ok := ctx.Deadline()
if !ok {
return context.WithCancel(ctx) // no overall budget: no share to compute
}
left := time.Until(dl)
return context.WithTimeout(ctx, left/time.Duration(remainingCalls))
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
for calls := 3; calls > 0; calls-- {
c, done := callWithShare(ctx, calls)
dl, _ := c.Deadline()
fmt.Printf("%d call(s) left: this call gets about %v\n", calls, time.Until(dl).Round(25*time.Millisecond))
done()
time.Sleep(20 * time.Millisecond) // work that used some of the budget
}
}3 call(s) left: this call gets about 100ms
2 call(s) left: this call gets about 150ms
1 call(s) left: this call gets about 250msEach call receives an equal share of what remains, so earlier calls that finish quickly leave more for later ones. Other policies are as easy: reserve a fixed amount for a final step, or skip an optional call if less than some minimum remains (if time.Until(dl) < 50*time.Millisecond { skip }), which avoids starting work that cannot finish. A function that checks the deadline up front and fails fast with a clear error is better than one that starts and times out in the middle (cancellation in web services shows the request-wide version).
Reasons for a timeout, in the error
WithTimeoutCause and WithDeadlineCause (Go 1.21) attach an error that context.Cause returns when the deadline fires, so you can say which budget expired:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeoutCause(context.Background(), 20*time.Millisecond,
errors.New("billing call exceeded its 20ms budget"))
defer cancel()
<-ctx.Done()
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
}Err: context deadline exceeded
Cause: billing call exceeded its 20ms budgetErr() still reports context deadline exceeded, so existing checks work, while Cause names the specific budget. With several nested timeouts this turns "something timed out" into "the billing call timed out" (context cause).
Absolute deadlines across services
The most important use of an absolute deadline is across a service boundary. A request arrives with a budget (an HTTP header carrying a deadline, or the gRPC metadata that carries one automatically), and the service calls two others. If it passes each call a fresh WithTimeout(ctx, 2*time.Second), the three layers can each spend two seconds, and the caller who allowed three has long gone. If it passes the same deadline down, and each layer subtracts a small margin, the whole call tree stays inside the original budget. In gRPC this is built in; for HTTP you carry it yourself in a header and apply it in middleware with WithDeadline, as the web service article shows. Clock skew between machines makes absolute timestamps a poor thing to send as wall-clock time, so propagate the remaining duration in the header and convert it to a deadline on arrival with WithTimeout. The two constructors end up cooperating: durations travel between machines, and deadlines are used within one.
Side-by-side
WithTimeout | WithDeadline | |
|---|---|---|
| Argument | a duration | an absolute time |
| Starts counting | when called | at the given instant |
| Natural use | per-call limit | shared budget, propagated deadline |
| Equivalent to | WithDeadline(parent, time.Now().Add(d)) | the primitive |
| Can extend a parent's deadline | no | no |
Needs defer cancel() | yes | yes |
| Retry loops | restarts the clock each attempt | bounds the total |
Common mistakes
Restarting the timeout on each retry and overrunning the budget, as shown. Not calling cancel. A timeout context releases its timer on expiry, but until then (and if the work finishes early) the cancel function frees it sooner, and go vet flags the omission. Setting a timeout in a library function that the caller's context already bounds, which adds a second, hidden limit that surprises the caller; accept the caller's deadline and let them choose. Using a zero or negative duration, which creates a context that is already expired: WithTimeout(ctx, 0) returns a context whose Done is closed immediately, a useful property when a computed remaining time has run out and a confusing one when the value is a bug. Comparing a deadline to the wall clock with time.Now() arithmetic by hand. Use time.Until(dl), which uses the monotonic reading.
The reference is the context.WithDeadline documentation, which states that it is the earlier of the parent's deadline and the one given, and the WithTimeout entry, which gives the equivalence to WithDeadline.


