JavaRush /Courses /Go SELF /Removing an element from a slice: stable vs unstable remo...

Removing an element from a slice: stable vs unstable remove

Go SELF
Level 13, Lesson 3
Available

1. What it really means to “remove an element” from a slice

When a beginner hears “remove from an array,” the brain draws an Excel-like picture: select a row, press Delete, and the world gets better. In Go it’s more pragmatic: a slice is a “window” onto an array, and it has no built-in operation to “rip an element out completely.” Removal is a careful combination of shifting and changing len, sometimes with additional cleanup of the tail.

Almost any removal from []T can be broken into three steps:

  1. close the gap (usually by shifting elements),
  2. reduce the length (reslice),
  3. if needed, clear the tail (so you don’t leave garbage and don’t keep memory alive).

Schematically it looks like this:

Was:     [A B C D E]  len=5
Remove C (i=2)

Shift:   [A B D E E]  len=5   (gap closed, but the tail "duplicates")
Reslice: [A B D E]    len=4
Clear:   [A B D E _]  (if we want to zero out the freed slot)

It looks a bit “low-level,” but it gives full control: we know exactly what happened to the data and why.

2. Stable remove: removing while preserving order

Stable remove is “human-style” removal: if elements were in some order, then after removal they stay in that same order, just without one element. This is ideal for task lists, action history, UI display queues, and in general anything where order matters. The cost is that we copy (shift) a chunk of the tail, which means a bit more work.

How stable remove works via copy

The most straightforward recipe looks like this: if we remove the element at index i, we copy the tail s[i+1:] into s[i:].

package main

import "fmt"

func main() {
	s := []int{10, 20, 30, 40}
	i := 1

	copy(s[i:], s[i+1:])
	fmt.Println(s) // [10 30 40 40]
}

Notice the “duplicate” at the end. That’s normal: copy overwrote elements, but it did not change the length, so the last element remained “as it was” (or became a copy of the previous one). To finish the removal, we need to reduce len.

Finishing the removal: reduce len and clear the tail

Now let’s wrap the removal into a function. Important: since the length changes, the function must return a new slice, and the caller must store that result.

package main

import "fmt"

func removeAtStable(s []string, i int) []string {
	if i < 0 || i >= len(s) {
		return s
	}
	copy(s[i:], s[i+1:])
	clear(s[len(s)-1:]) // tail hygiene
	return s[:len(s)-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	fmt.Println(removeAtStable(tasks, 1)) // [read sleep]
}

Here clear(s[len(s)-1:]) clears the freed slot. For string this is especially useful as a discipline: you explicitly say “this element is no longer needed,” and the tail doesn’t keep an old reference to the string data.

3. Unstable remove: removing without preserving order

Unstable remove is for cases where element order doesn’t matter, and speed and memory behavior matter more. The idea is very simple and a bit cheeky: “if order doesn’t matter, let’s put the last element into the removed element’s slot, and then reduce the length.” The tail barely moves, so there’s usually less work.

Imagine you have a list of temporary IDs, a set of active connections, an object pool, a collection of “candidates” where order doesn’t affect the result. There unstable remove can be very handy.

How unstable remove works (swap-with-last)

Here is the minimal logic:

package main

import "fmt"

func main() {
	s := []int{10, 20, 30, 40}
	i := 1
	last := len(s) - 1

	s[i] = s[last]
	s = s[:last]

	fmt.Println(s) // [10 40 30]
}

We removed “20,” but “40” jumped into its place. The order changed—this is what “unstable” means.

An unstable remove function with tail cleanup

Let’s write a neat function which, like the stable variant, checks bounds, returns a new slice, and clears the last slot.

package main

import "fmt"

func removeAtUnstable(s []string, i int) []string {
	n := len(s)
	if i < 0 || i >= n {
		return s
	}
	s[i] = s[n-1]
	clear(s[n-1:]) // so we don't keep a reference in the tail
	return s[:n-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	fmt.Println(removeAtUnstable(tasks, 1)) // [read sleep] (order may change)
}

In this particular example, the result may look the same as stable because “sleep” was already last. But if you removed index 0, you would see a swap.

4. Stable vs unstable: how to choose a strategy

When you choose a removal strategy, you are really choosing “what price I pay for preserving order.” Stable remove pays by copying the tail; unstable pays by breaking the order.

Here’s a small table to lock it in:

Criterion Stable remove Unstable remove
Element order preserved not preserved
How many elements we “move” usually many (the tail) usually 1 (the last)
Typical use cases user-facing lists, history, ordered data a “bag” of elements where order doesn’t matter
Risk of surprises lower higher (order changes)

One more important thought: stable remove is easier on the human brain because it matches expectations. Unstable remove is easier on the computer. In real life the brain often wins, but sometimes you truly need a “bag,” and then unstable remove is honest and fast.

A short “official” removal pattern via append

There is a popular one-liner for removing an element that you will often see in other people’s code:

s = append(s[:i], s[i+1:]...)

In effect this is also stable remove: we take the “head” s[:i] and the “tail” s[i+1:] and glue them together via append. This approach is indeed considered classic and is widely used as the standard recipe for removing a range from a slice.

But it’s important to understand two things so you don’t turn this recipe into a magic spell.

First, it’s an operation that changes the length, so the result must be assigned back into s. Second, under the hood it still copies the tail elements—you’re just expressing it “in one line” instead of in two steps copy + reslice. When you write code for learning or debugging, the copy + s[:newLen] variant is sometimes easier to read and comment.

5. Mini app “To-do list” and removing tasks

Now we’ll again assemble a small console “app” that relies only on what you already know: slices, functions, fmt, copy, clear. Let it be a simple model: the task list is []string, and removing a task is removal by index. No structs and no files—just straightforward slice work.

Printing tasks with human-friendly numbering

A user usually counts from 1, but indexes in Go start at 0. This is a classic source of pain, so let’s create a print function that shows numbers “the human way.”

package main

import "fmt"

func printTasks(tasks []string) {
	for i, t := range tasks {
		fmt.Printf("%d) %s\n", i+1, t)
	}
}

func main() {
	printTasks([]string{"read", "code", "sleep"})
	// 1) read
	// 2) code
	// 3) sleep
}

Removing a task: stable variant

Now let’s add stable remove in the context of “tasks.” Here order almost always matters: if you wrote a to-do list, it’s weird if after deleting the second item the third suddenly swaps places with the last one (psychologically it feels like “the app broke”).

package main

import "fmt"

func removeTaskStable(tasks []string, humanNumber int) []string {
	i := humanNumber - 1
	if i < 0 || i >= len(tasks) {
		return tasks
	}
	copy(tasks[i:], tasks[i+1:])
	clear(tasks[len(tasks)-1:])
	return tasks[:len(tasks)-1]
}

func main() {
	tasks := []string{"read", "code", "sleep"}
	tasks = removeTaskStable(tasks, 2)
	fmt.Println(tasks) // [read sleep]
}

Note: we accept humanNumber, and inside we immediately convert it to an index. This reduces the chance of off-by-one errors, because the conversion is in one place and is obvious.

Removing a task: unstable variant

Now imagine a different situation: we have a list of “temporary markers,” “subscriptions,” or “active ids,” and order doesn’t matter at all. We want to quickly remove an element and not shift the tail.

package main

import "fmt"

func removeTaskUnstable(tasks []string, humanNumber int) []string {
	i := humanNumber - 1
	n := len(tasks)
	if i < 0 || i >= n {
		return tasks
	}
	tasks[i] = tasks[n-1]
	clear(tasks[n-1:])
	return tasks[:n-1]
}

func main() {
	tasks := []string{"A", "B", "C", "D"}
	tasks = removeTaskUnstable(tasks, 2)
	fmt.Println(tasks) // e.g.: [A D C]
}

Here it’s important to comment honestly: “order changes.” Unstable remove is good when that’s expected and doesn’t confuse the user.

6. Tail and memory: why clear next to removal is useful

When you’re just starting out, clear next to removal looks like “extra cleaning.” Like, we already reduced len—who will ever see the tail? The problem is that the tail may be invisible, but it can still affect memory: as long as the backing array keeps a reference to data, the garbage collector may consider that data reachable and not free it.

This topic isn’t invented for a textbook. Even authors of standard functions had to decide whether to clear “stale elements” after removal, because otherwise users accidentally retained memory via the tail. As a result, some standard operations in newer versions of Go began to explicitly “clean the tail” via clear to make behavior less surprising.

So in educational examples we build a habit: if you reduced len, think about whether you should zero out the freed elements. For []int it’s often a matter of neatness. For []string it’s already a matter of memory hygiene. For slices containing “heavy” references (for example, large strings), it can become noticeable.

Indexes: a mini flowchart for removal

Removal often breaks not because of copy, but because of indexes. So it’s useful to keep a simple “mental flowchart” in front of your eyes, especially when you write your first code without autocompletion.

flowchart TD
    A[We have a slice s and an index i] --> B{"0 <= i < len(s)?"}
    B -- no --> C[Do nothing, return s]
    B -- yes --> D["Stable: copy(s[i:], s[i+1:])"]
    D --> E["clear(s[len-1:]) if needed"]
    E --> F["reslice: s = s[:len-1]"]
    F --> G[Return the new s]

If you choose unstable remove, the middle changes to “s[i] = s[len-1]”, and then everything is the same: (optionally) clear the tail and reduce len.

8. Typical mistakes when removing an element from a slice

Error #1: you did copy, but forgot to reduce len.
This is very common: you shifted the tail, looked at the slice, saw “almost correct,” but there is an extra duplicate element sticking out at the end. The fix is simple: remember that copy never changes len, and the final removal step is s = s[:len(s)-1].

Error #2: you forgot to store the result of the removal function.
If you wrote tasks = removeTaskStable(tasks, 2)—all good. If you wrote just removeTaskStable(tasks, 2), then inside the function the backing array could be modified, but the length in your tasks variable will remain the same. You’ll get a weird half-result: elements seem shifted, but the list didn’t get shorter.

Error #3: you mixed up the “human number” and the index.
The user says “delete task number 1,” and you delete tasks[1], i.e. the second one. This is the classic off-by-one that follows programmers like taxes—sometimes suddenly. Best prevention: convert humanNumber to i := humanNumber - 1 in one place, and then use only i everywhere.

Error #4: you use unstable remove where order matters.
Unstable remove can be great, but if you apply it to a “to-do list,” the user will see elements jump around and conclude “the program is glitchy.” That’s not an algorithm bug; it’s a UX bug. If order matters—use stable remove, even if it copies more.

Error #5: you didn’t clear the tail, and then you were surprised by memory use or “ghost” values.
Even if you don’t plan to reslice back up to cap, the tail is still part of the backing array. For reference types, it can retain memory. The habit of clear(s[newLen:oldLen]) (or at least clear(s[len(s)-1:]) when removing a single element) makes behavior more honest and predictable, especially when the data is “heavy.”

1
Task
Go SELF, level 13, lesson 3
Locked
Remove cleanly
Remove cleanly
1
Task
Go SELF, level 13, lesson 3
Locked
Remove quickly
Remove quickly
1
Task
Go SELF, level 13, lesson 3
Locked
Removal by input
Removal by input
1
Task
Go SELF, level 13, lesson 3
Locked
Removal mode
Removal mode
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION