1. What is s[a:b]: the half-open interval [a, b)
When you first see the notation s[a:b], your brain often tries to read it as “take elements from a to b.” And that’s where the classic series “off-by-one: the beginning” starts. In reality, s[a:b] in Go is built like a neat mathematical idea — a half-open interval: include the start, exclude the end. It’s very convenient, but feels unusual at first.
Notation:
part := s[a:b]
means: “take elements with indexes a, a+1, ..., b-1.”
So it’s not [a, b], but [a, b).
Let’s quickly look at the simplest example:
package main
import (
"fmt"
)
func main() {
s := []int{10, 20, 30, 40, 50}
part := s[1:4]
fmt.Println(part) // [20 30 40]
}
Why is it like that? Because we take indexes 1, 2, 3, and index 4 is “the position right after the last one.” That’s exactly what a half-open interval is.
Helpful visualization (indexes on top, elements below):
indexes: 0 1 2 3 4
s: 10 20 30 40 50
s[1:4] => indexes 1..3 => 20 30 40
2. Slice bounds: rules and how they differ from indexing
The most unpleasant part of slices is not append, not make, and not even the “window onto an array” philosophy. The most unpleasant part is the bounds. Because Go does not forgive boundary mistakes: if you go out of range, you’ll get panic, and the program will end with a very honest message that you asked for something that doesn’t exist.
Here it’s important to separate two operations that look similar, but are checked differently:
- Indexing — access an element: s[i]
- Slicing — create a new window: s[a:b]
For indexing, the rule is strict and simple:
- it must be 0 <= i < len(s)
That is, i must be less than the length, otherwise “that element doesn’t exist.”
For slicing s[a:b] the rule is different (and this is the fact of the day):
- it must be 0 <= a <= b <= cap(s)
Yes, the upper bound is compared against cap, not necessarily against len. This looks strange if you just learned “a slice has len elements.” But the logic is: slicing changes the “window,” and in principle the window can expand up to the capacity of the allocated buffer.
At the same time (and this is a good “engineering” tip for beginners), if you don’t want surprises, then in application code you almost always keep a more “human” invariant: 0 <= a <= b <= len(s), and you use expansion up to cap deliberately.
To feel the difference, here’s an example: you can’t index, but sometimes you can still “slice” (if cap allows it):
package main
import (
"fmt"
)
func main() {
s := make([]int, 0, 3) // len=0, cap=3
fmt.Println(s[0]) // panic: index 0 is unavailable when len=0
t := s[:1] // ok: b=1 <= cap=3
fmt.Println(t, len(t)) // [0] 1
}
Here t shows the first element of the backing array (it will be 0, because that is the zero value for int). We’re not going deeper yet into why it’s there — what matters is that the bounds are checked differently.
Officially, all of this relates to slice expressions — it’s a separate part of the language rules.
How to predict the result len and cap in advance
A slice expression s[a:b] returns a new slice, which has its own len and cap. And this is where useful math begins — the kind that saves you from “magical thinking.”
The rules are simple:
- len(s[a:b]) == b - a
- cap(s[a:b]) == cap(s) - a (usually exactly like this, because the start of the window shifts to the right)
Why does cap decrease by a? Because capacity is “how much space exists to the right of the window start until the end of the backing array.” If you move the window start, there is less space to the right.
Let’s print it in code:
package main
import (
"fmt"
)
func main() {
s := make([]int, 5, 10) // len=5 cap=10
part := s[2:5] // take 3 elements
fmt.Printf("len=%d cap=%d\n", len(part), cap(part)) // len=3 cap=8
}
len became 5-2=3. And cap became 10-2=8.
By the way, if you’ve seen (or written) code that reslices via list = list[:n+1], that’s exactly the same mechanism: the window changes, and no new array is created.
3. Omitted bounds: s[:b], s[a:], s[:]
When you start slicing in real tasks, you quickly realize: writing 0 and len(s) every time is a bit tiring. So Go has convenient shorthands: you can omit the start or the end, and the compiler will substitute the obvious value.
Three most common forms work like this:
- s[:b] means s[0:b] (from the beginning)
- s[a:] means s[a:len(s)] (to the end of the current length)
- s[:] means s[0:len(s)] (everything, but as a “new window”)
Example — as practical as it gets:
package main
import (
"fmt"
)
func main() {
s := []int{10, 20, 30, 40, 50}
fmt.Println(s[:2]) // [10 20]
fmt.Println(s[3:]) // [40 50]
fmt.Println(s[:]) // [10 20 30 40 50]
}
Pay attention to s[:]. This is not a “copy” of elements. It’s just a new “window” onto the same data (we’ll discuss the details of this behavior later), but for now remember something useful: s[:] is often used as “take everything,” but already as a slice (for example, when you need to pass along a subset that in fact equals the entire set).
One more important point: an empty slice via slicing is normal. If a == b, you get an empty result, and that is not an error:
package main
import (
"fmt"
)
func main() {
s := []int{1, 2, 3, 4}
empty := s[2:2]
fmt.Println(empty) // []
fmt.Println(len(empty)) // 0
}
4. Off-by-one: the most popular “one element” bug
Off-by-one is when you wanted “inclusive” but got “exclusive,” or you wanted 3 elements but took 2 (or 4). And it’s not a “beginner mistake.” It’s everyone’s mistake: beginners, mid-level devs, seniors, and people who “know it all, but it’s Friday.”
The reason is simple: people think in inclusive ranges (“from 1 to 10”), while s[a:b] lives in a world of the position after the last.
To keep your brain from boiling, it helps to keep a few practical patterns in mind.
Pattern: take N elements starting at i
If you want to take N elements starting at index i, then the upper bound is i + N:
chunk := s[i : i+N]
Check: the length of chunk is (i+N) - i = N. Honest and simple.
Mini example:
package main
import (
"fmt"
)
func main() {
s := []int{1, 2, 3, 4, 5}
fmt.Println(s[1:1+3]) // [2 3 4]
}
Pattern: take everything up to index i inclusive
This is where things break most often. If you want to include i, then the upper bound must be i+1:
prefix := s[:i+1]
Example:
package main
import (
"fmt"
)
func main() {
s := []int{10, 20, 30, 40}
fmt.Println(s[:2+1]) // [10 20 30]
}
Pattern: take the last K elements
Take the start as len(s)-K, and the end as len(s):
tail := s[len(s)-K:]
Example:
package main
import (
"fmt"
)
func main() {
s := []int{10, 20, 30, 40, 50}
fmt.Println(s[len(s)-2:]) // [40 50]
}
The main thing is not to forget to check that K <= len(s), otherwise len(s)-K becomes negative, and you’ll get panic.
Quick reference table
| What you want | What to write in Go |
|---|---|
| Elements from a to b-1 | |
| The first n elements | |
| Everything except the first n | |
| The last k | |
| Include index i in “up to i” | |
| Take exactly n starting at position i | |
5. Mini feature: show tasks by range
Let’s write a small educational console app — a tiny task manager. For now it’s just a slice of strings that we can print to the screen. Today we’ll add “range view”: the user enters task numbers (human-friendly, starting from 1), and the program shows the corresponding part of the list.
Sounds simple, but there are two classic traps. First, the user thinks “inclusive” (“show from 2 to 4”), while Go slices as [a, b). Second, the user enters 1-based numbers, but the slice uses 0-based indexes. A perfect combo to practice off-by-one and survive.
Let’s start with a data stub:
package main
import (
"fmt"
)
func main() {
tasks := []string{"learn Go", "write code", "drink water", "sleep"}
fmt.Println(tasks) // [learn Go write code drink water sleep]
}
Now let’s write a piece of code that reads from and to as task numbers (1..N) and converts them to indexes a and b for s[a:b]. Let’s agree: the user enters an inclusive range, so “2 4” should show tasks No. 2, No. 3, No. 4.
Then:
- a = from - 1
- b = to (because b is not included, while the user “includes” to)
Here’s a short example:
package main
import (
"fmt"
)
func main() {
tasks := []string{"learn Go", "write code", "drink water", "sleep"}
var from, to int
fmt.Scan(&from, &to)
a := from - 1
b := to
fmt.Println(tasks[a:b])
}
It works… right up until the first “bad” input. If the user enters 0 2 or 3 100, the program will crash. So we need to minimally “harden” the bounds. We’re not designing errors as a separate concept yet, so we’ll do a simple approach: if the bounds are invalid, print an empty range.
Here is a more careful version, still short:
package main
import (
"fmt"
)
func main() {
tasks := []string{"learn Go", "write code", "drink water", "sleep"}
var from, to int
fmt.Scan(&from, &to)
a := from - 1
b := to
if a < 0 || b < 0 || a > b || b > len(tasks) {
fmt.Println([]string{}) // []
return
}
fmt.Println(tasks[a:b])
}
Here’s the key idea: we check b > len(tasks), because for a normal user range we don’t want to slice up to cap. We want predictable output exactly over the real elements.
And yes, note the condition a > b. If the user entered 4 2, that’s not an “empty range,” it’s just a weird request. We calmly turn it into an empty result instead of a panic.
6. Common mistakes when working with s[a:b]
Mistake No. 1: thinking the upper bound is inclusive.
This is the most common source of off-by-one errors. When you write s[1:4], you take 3 elements, not 4. A useful habit is to read it out loud as “from 1 inclusive to 4 exclusive,” and remember the length formula: b-a.
Mistake No. 2: confusing indexing s[i] with slicing s[a:b].
Indexing requires i < len(s) and doesn’t tolerate compromises. Slicing, on the other hand, works with the “window” idea and allows bounds up to cap. If you keep these as two different actions (element access vs. building a new window), it becomes easier to understand why one crashes while the other sometimes “suddenly works.”
Mistake No. 3: mapping “human” numbers (1-based) to indexes (0-based) without an explicit step.
If the user enters “2,” it almost never means index 2. Usually it means “the second element,” i.e., index 1. So it’s better to do the conversion explicitly, with separate variables: a := from - 1, b := to, and comment the meaning directly. This reduces the chance of an “off by one” in the next change.
Mistake No. 4: not checking bounds before slicing and getting a panic for no reason.
In learning tasks, panic often looks like “well, it crashed and it crashed,” but in application code it’s simply a failure. Even if you’re not designing errors as values yet, a minimal check like 0 <= a, a <= b, b <= len(s) makes the code calmer and more predictable.
Mistake No. 5: expecting s[:] to create a copy.
Intuitively it feels like “took everything” means “copied everything.” But slicing creates a new view (a new window), not a new set of elements. This isn’t scary while you’re only reading, but it can surprise you when modifications start happening somewhere. We’ll return to this topic separately; for now it’s enough not to conclude “slice = copy.”
GO TO FULL VERSION