A slice is a view into a sequence of values.
A slice does not own the values. It points to existing memory and stores how many items are available.
The type of a slice looks like this:
[]TRead it as:
slice of TFor example:
[]u8means:
slice of u8 valuesAnd:
[]const u8means:
slice of read-only u8 valuesSlices are one of the most common types in Zig. They are used for arrays, buffers, strings, file contents, network data, and many standard library APIs.
A Slice Is Pointer Plus Length
A slice contains two pieces of information:
| Part | Meaning |
|---|---|
| pointer | address of the first item |
| length | number of items |
This is why slices are safer than many item pointers.
A many item pointer says:
[*]u8That means “there are some u8 values starting here,” but it does not say how many.
A slice says:
[]u8That means “there are u8 values starting here, and this slice knows how many.”
Because the slice has a length, Zig can check indexes in safe build modes.
Creating a Slice from an Array
You can create a slice from an array using range syntax:
const std = @import("std");
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const slice = data[0..];
std.debug.print("{any}\n", .{slice});
}Output:
{ 10, 20, 30, 40 }The expression:
data[0..]means:
from index 0 to the endSo slice views the whole array.
You can also slice part of the array:
const middle = data[1..3];This means:
start at index 1, stop before index 3So it contains:
20, 30The start index is included. The end index is excluded.
Slice Ranges Are Half-Open
Zig slice ranges use half-open intervals:
start..endThis includes start, but excludes end.
Example:
var data = [_]u8{ 10, 20, 30, 40 };
const a = data[0..2]; // 10, 20
const b = data[2..4]; // 30, 40This is useful because the length is easy to calculate:
end - startSo:
data[1..3]has length:
3 - 1 = 2It contains two values:
data[1], data[2]Reading from a Slice
You can read values from a slice using indexes:
const std = @import("std");
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const slice = data[1..3];
std.debug.print("{}\n", .{slice[0]});
std.debug.print("{}\n", .{slice[1]});
}Output:
20
30The slice starts at data[1], but the slice’s own indexes start at 0.
So:
slice[0] is data[1]
slice[1] is data[2]This is important. A slice has its own view of the memory.
Writing Through a Mutable Slice
If the slice is mutable, you can change the underlying memory through it.
const std = @import("std");
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const slice = data[1..3];
slice[0] = 99;
std.debug.print("{any}\n", .{data});
}Output:
{ 10, 99, 30, 40 }The slice did not copy the array. It pointed into the same memory.
This line:
slice[0] = 99;changed data[1].
That is the key rule:
Changing a slice changes the memory it views.
Const Slices
A const slice lets you read values, but not modify them.
const data = [_]u8{ 10, 20, 30, 40 };
const slice: []const u8 = data[0..];The type:
[]const u8means:
slice of constant u8 valuesYou can read:
const x = slice[0];But you cannot write:
// slice[0] = 99; // errorThis is the standard way to pass read-only buffers to functions.
Example:
fn printBytes(bytes: []const u8) void {
for (bytes) |byte| {
_ = byte;
}
}The function promises not to modify the bytes.
Mutable Slice vs Const Slice
These two types are different:
[]u8
[]const u8A []u8 slice allows mutation.
A []const u8 slice only allows reading.
A mutable slice can be passed where a const slice is expected:
fn readOnly(bytes: []const u8) void {
_ = bytes;
}
pub fn main() void {
var data = [_]u8{ 1, 2, 3 };
const mutable_slice: []u8 = data[0..];
readOnly(mutable_slice);
}This is allowed because a function that only reads is safe to call with mutable data.
But the reverse is not allowed. You cannot pass read-only data to a function that may modify it.
fn modify(bytes: []u8) void {
bytes[0] = 99;
}
pub fn main() void {
const data = [_]u8{ 1, 2, 3 };
const readonly_slice: []const u8 = data[0..];
// modify(readonly_slice); // error
}This protects data that should not be changed.
Slice Length
Every slice has a .len field.
const std = @import("std");
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const slice = data[1..3];
std.debug.print("len = {}\n", .{slice.len});
}Output:
len = 2The length is the number of items, not the number of bytes.
For []u8, the number of items and number of bytes are the same.
For []u32, they are different:
var numbers = [_]u32{ 1, 2, 3 };
const slice = numbers[0..];
std.debug.print("{}\n", .{slice.len}); // 3The slice length is 3, because there are 3 u32 values.
The memory size is larger because each u32 uses 4 bytes.
Iterating Over a Slice
Use for to loop over a slice:
const std = @import("std");
pub fn main() void {
const data = [_]u8{ 10, 20, 30 };
for (data[0..]) |value| {
std.debug.print("{}\n", .{value});
}
}Output:
10
20
30Here, value is a copy of each item.
If you want to modify each item, capture a pointer:
pub fn main() void {
var data = [_]u8{ 10, 20, 30 };
for (data[0..]) |*value| {
value.* += 1;
}
}After the loop, data contains:
11, 21, 31The |*value| syntax means each loop variable is a pointer to the current item.
Then:
value.*accesses the actual item.
Passing Slices to Functions
Slices are the normal way to pass a sequence to a function.
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| {
total += value;
}
return total;
}Use it like this:
const std = @import("std");
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| {
total += value;
}
return total;
}
pub fn main() void {
const numbers = [_]i32{ 1, 2, 3, 4 };
const result = sum(numbers[0..]);
std.debug.print("sum = {}\n", .{result});
}Output:
sum = 10The function does not care whether the values came from a fixed array, heap allocation, or part of another buffer. It only needs a slice.
This is one reason slices are so useful. They separate “how memory was created” from “how memory is used.”
Returning Slices
A function can return a slice, but the memory behind the slice must still be valid.
This is wrong:
fn badSlice() []u8 {
var data = [_]u8{ 1, 2, 3 };
return data[0..];
}The array data is local to badSlice. When the function returns, data is gone. The returned slice would point to invalid memory.
Instead, the caller can provide the memory:
fn firstTwo(buffer: []u8) []u8 {
return buffer[0..2];
}Use it like this:
const std = @import("std");
fn firstTwo(buffer: []u8) []u8 {
return buffer[0..2];
}
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const result = firstTwo(data[0..]);
std.debug.print("{any}\n", .{result});
}Output:
{ 10, 20 }This is valid because data lives in main, and the returned slice still points into valid memory.
The rule is:
A slice must not outlive the memory it points to.
Empty Slices
A slice can be empty.
const empty = data[0..0];Its length is 0.
An empty slice still has a type:
[]u8or:
[]const u8You can pass empty slices to functions safely, as long as the function handles length 0.
Example:
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| {
total += value;
}
return total;
}If values.len is 0, the loop runs zero times, and the result is 0.
Slicing a Slice
You can create a smaller slice from an existing slice.
var data = [_]u8{ 10, 20, 30, 40, 50 };
const all = data[0..];
const middle = all[1..4];middle contains:
20, 30, 40Again, this does not copy the data. It creates another view into the same memory.
You can keep slicing:
const smaller = middle[1..2];smaller contains:
30All of these slices still refer to the original array.
Slices and Strings
In Zig, strings are usually byte slices.
A string literal has a type related to an array of bytes, and it can be used as:
[]const u8Example:
const message: []const u8 = "hello";This means:
message is a read-only slice of bytesThe string "hello" contains bytes for the characters:
h e l l oSo:
message.lenis:
5Zig strings are bytes. They are often UTF-8 text, but Zig does not hide that from you. This means string processing is also byte processing unless you explicitly handle Unicode code points.
Slice Bounds
A slice knows its length, so Zig can check whether an index is valid in safe modes.
This is valid:
const x = slice[0];only if:
slice.len > 0This is invalid if the slice has length 3:
const x = slice[3];The valid indexes are:
0, 1, 2Index 3 is one past the end.
The same applies to slicing:
const part = slice[1..3];This is valid only if slice.len >= 3.
Bad indexes are bugs. Zig tries to catch them early.
Common Pattern: Buffer In, Slice Out
Many Zig functions receive a buffer and return the part that was actually used.
Example:
fn writeHello(buffer: []u8) []u8 {
buffer[0] = 'h';
buffer[1] = 'i';
return buffer[0..2];
}Use it like this:
const std = @import("std");
fn writeHello(buffer: []u8) []u8 {
buffer[0] = 'h';
buffer[1] = 'i';
return buffer[0..2];
}
pub fn main() void {
var storage: [16]u8 = undefined;
const used = writeHello(storage[0..]);
std.debug.print("{s}\n", .{used});
}Output:
hiThe function receives storage from the caller. It writes into it. Then it returns a slice showing which part contains useful data.
This pattern avoids heap allocation.
Common Mistake: Thinking a Slice Owns Memory
A slice does not own memory.
This code:
const part = data[1..3];does not create a new array.
It creates a view into data.
If data changes, the slice sees the change.
If data becomes invalid, the slice becomes invalid too.
This is the most important thing to remember about slices.
Common Mistake: Returning a Slice to Local Data
This is invalid design:
fn makeName() []const u8 {
const name = [_]u8{ 'z', 'i', 'g' };
return name[0..];
}The slice points into name, but name dies when the function returns.
Instead, use one of these patterns:
Return a string literal:
fn name() []const u8 {
return "zig";
}String literals have static storage, so this is fine.
Or let the caller provide storage:
fn writeName(buffer: []u8) []u8 {
buffer[0] = 'z';
buffer[1] = 'i';
buffer[2] = 'g';
return buffer[0..3];
}Or allocate memory and make ownership clear:
fn makeName(allocator: std.mem.Allocator) ![]u8 {
const result = try allocator.alloc(u8, 3);
result[0] = 'z';
result[1] = 'i';
result[2] = 'g';
return result;
}In the allocation version, the caller must later free the returned slice.
The Main Idea
A slice is a pointer plus a length.
It views memory that exists somewhere else.
Use slices for buffers, arrays, strings, and function parameters that operate on sequences.
Prefer slices over many item pointers in normal Zig code, because slices carry their length and are easier to use safely.
The central rule is simple:
A slice is only valid while the memory behind it is valid.