How to Delete an Element from a Slice in Go

Delete a Go slice element by swapping it with the last item and truncating the slice length.

The missing delete function

You are building a task tracker. The user clicks a trash icon on the third item. You reach for a familiar function, type delete(mySlice, 2), and hit save. The compiler immediately rejects the program with undefined: delete. Go does not have a built-in delete function for slices. It has one for maps, but slices work differently. You need to manipulate the slice header yourself.

This absence catches developers coming from Python or JavaScript. Those languages treat lists and arrays as high-level objects with built-in mutation methods. Go treats slices as lightweight views over raw memory. The language expects you to understand what happens under the hood. Once you see the slice header, the deletion patterns become obvious.

How slices actually store data

A slice is not a standalone container. It is a three-word descriptor that points to an underlying array. The descriptor holds a pointer to the first element, the current length, and the maximum capacity. When you create a slice with make([]int, 5, 10), Go allocates an array of ten integers on the heap. The slice header says the pointer starts at index zero, the length is five, and the capacity is ten.

Deleting an element never frees the underlying array. It only changes the length field in the slice header. The array stays exactly where it is. The old value remains in memory until the garbage collector eventually reclaims the entire backing array, or until you overwrite it. This design keeps slices cheap to pass around. Functions receive a copy of the three-word header, not a copy of the data.

Understanding this header changes how you approach removal. You have two main goals: adjust the length, and decide what to do with the gap you just created. The gap is the only real decision. Go does not hide this choice behind a single API. You pick the strategy that matches your performance and correctness requirements.

The fastest path: swap and truncate

Order does not matter in many systems. A pool of available connections, a list of background jobs, or a set of pending notifications rarely requires a fixed sequence. When sequence is irrelevant, the fastest deletion strategy replaces the target element with the last element, then shrinks the length by one.

Here is the simplest implementation:

// RemoveIndexFast deletes an element by swapping it with the last item.
func RemoveIndexFast(s []string, i int) []string {
	// Swap the target with the final element to fill the gap instantly.
	s[i] = s[len(s)-1]
	// Truncate the slice length to hide the duplicated last element.
	return s[:len(s)-1]
}

This approach runs in constant time. It performs exactly two assignments regardless of slice size. The memory layout stays contiguous. No elements shift. The garbage collector sees no movement. You get O(1) performance with zero allocations.

The tradeoff is straightforward. The element that used to sit at the end now lives at index i. If your code depends on stable ordering, this pattern breaks expectations. If your code treats the slice as a bag of items, this pattern is optimal.

Slice manipulation favors speed and predictability. Swap and truncate delivers both.

When order matters: shift and truncate

Some data structures require stable ordering. A timeline of events, a ranked leaderboard, or a sequence of UI components must keep their relative positions. Removing an item from the middle means every subsequent item must slide down by one slot.

Go provides the built-in copy function to handle this efficiently. The function moves elements within the same slice without allocating new memory. You pass the destination range and the source range, and Go handles the byte-level movement. The runtime guarantees safe overlapping copies.

Here is how you preserve order while removing an element:

// RemoveIndexOrdered deletes an element while keeping the remaining items in place.
func RemoveIndexOrdered(s []int, i int) []int {
	// Shift all elements after the target one position to the left.
	copy(s[i:], s[i+1:])
	// Overwrite the now-duplicated last element with the zero value.
	s[len(s)-1] = 0
	// Shrink the length to exclude the cleared slot.
	return s[:len(s)-1]
}

The copy function reads from the right side of the slice and writes to the left. It safely handles overlapping ranges. After the shift, the last index contains a duplicate of the second-to-last element. You explicitly zero it out to prevent stale data from lingering. Then you truncate the length.

This pattern runs in O(n) time. The cost scales linearly with the number of elements after the deletion point. For small slices, the difference between O(1) and O(n) is invisible. For slices with thousands of items, the shift adds measurable CPU cycles. The memory layout remains contiguous, and the backing array capacity stays unchanged.

Go developers accept the linear cost when order is a contract. Shift and truncate keeps the sequence intact without hiding complexity behind a black box.

Filtering instead of deleting

Deletion often appears inside a larger pattern. You rarely remove a single element in isolation. You usually scan a slice, find items that match a condition, and drop them. When multiple deletions happen in one pass, shifting elements repeatedly becomes expensive. Each removal triggers another O(n) copy operation. The total cost climbs to O(n²).

The idiomatic solution builds a new slice containing only the items you want to keep. You iterate once, append the survivors to a result slice, and replace the original. This approach runs in O(n) time regardless of how many items you drop. It also avoids zero-value artifacts and keeps the code readable.

Here is a realistic filtering pattern:

// FilterActiveUsers keeps only users whose status matches the active flag.
func FilterActiveUsers(users []User, active bool) []User {
	// Preallocate capacity to avoid repeated heap allocations during append.
	result := make([]User, 0, len(users))
	for _, u := range users {
		// Append only the items that satisfy the condition.
		if u.IsActive == active {
			result = append(result, u)
		}
	}
	return result
}

The make call reserves the maximum possible capacity upfront. The append function reuses that space until it fills up, then allocates a larger backing array and copies the data. This growth strategy keeps amortized append operations cheap. The original slice remains untouched until you reassign the variable. If you need to mutate the original variable in place, assign the result back to it.

Filtering trades a single allocation for predictable performance. It also eliminates the mental overhead of tracking indices during mutation. When you remove more than a handful of items, building a new slice is almost always the right choice.

Memory allocation is cheap in Go. Repeated shifting is not.

Common traps and compiler feedback

Slice deletion introduces a few predictable mistakes. The compiler catches some at build time. Others surface as runtime panics or silent data corruption.

Forgetting to reassign the slice is the most common oversight. Slices are passed by value. The function receives a copy of the header. If you modify the length inside a function but do not return the new header, the caller still sees the old length. The compiler will not stop you. The program will compile, but the slice will retain its original size. The deleted element remains visible to the caller. Always return the modified slice and reassign it.

Zero-value artifacts appear when you truncate without clearing. The backing array still holds the old data. If you later grow the slice back to its original capacity, the stale value reappears. This is why the ordered deletion pattern explicitly sets the last element to zero. The same rule applies to pointer types. If your slice holds *Widget, you must set the dangling pointer to nil before truncating. Leaving a live pointer to a logically deleted item invites use-after-free bugs.

Index out of range panics happen when you calculate the wrong bounds. The compiler rejects invalid constants with constant value overflows type, but runtime index errors produce a panic. Always validate the index before deletion. A simple if i < 0 || i >= len(s) { return s } guard prevents crashes.

Capacity confusion trips up newcomers. Truncating a slice does not shrink its capacity. The backing array stays allocated. If you want to return memory to the runtime, you must create a new slice with a smaller capacity and copy the data. Most applications do not need to do this. The garbage collector handles unused capacity when the slice goes out of scope.

Trust the slice header. Verify your indices. Clear your pointers.

Which technique fits your code

Go does not hide data structure tradeoffs behind a single API. You choose the pattern that matches your performance and correctness requirements.

Use swap-and-truncate when order does not matter and you need constant-time removal. Use shift-and-truncate when the sequence must stay stable and you are removing a single element. Use filter-into-new-slice when you are dropping multiple items or applying a conditional predicate. Use a map when you need fast lookups and frequent insertions or deletions without caring about order.

Pick the tool that matches the constraint. Do not optimize for hypothetical scale. Measure the actual workload.

Where to go next