Why Go Took 15 Months to Add 8 Lines of Code

The strings.CutLast Proposal Reveals how Go Thinks about Stdlib API Design — Permanent Decisions, Family Consistency, and why Real-world…

分享
medium-cover

GO PROPOSAL!

Why Go Took 15 Months to Add 6 Lines of Code

The strings.CutLast Proposal Reveals how Go Thinks about Stdlib API Design — Permanent Decisions, Family Consistency, and why Real-world Evidence Beats Elegant Intuition.

Here’s a function you’ve probably written yourself:

func cutLast(s, sep string) (before, after string, found bool) { 
    if i := strings.LastIndex(s, sep); i >= 0 { 
        return s[:i], s[i+len(sep):], true 
    } 
    return "", s, false 
}

Straightforward. Eight lines. Roger Peppe found five independent copies scattered across his own codebase, then discovered every single one was subtly wrong.

He said so in a GitHub comment: “I was actually using it wrong in all these cases!” That admission ended a 15-month debate about how strings.CutLast should behave when the separator isn't found.

So why does 8 lines of code take 15 months in Go? What’s actually going on in those discussions?

The Proposal: a Function Everyone Already Wrote

In January 2025, @chipaca opened issue #71151 proposing strings.CutLast a complement to strings.Cut those searches from the right instead of the left.

strings.Cut was a 2021 addition, championed by Russ Cox as a single function to replace the overwhelming majority of Index, IndexByte, IndexRune, and SplitN usage (#46336):

// Instead of: 
i := strings.Index(s, ":") 
if i >= 0 { 
    user, pass = s[:i], s[i+1:] 
} 
  
// Just: 
user, pass, _ := strings.Cut(s, ":")

CutLast extends this to the right-to-left case: parse a URL slug, extract a file extension, split a package URL at its last separator. The kind of operation you reach for every few months and always end up hand-rolling.

The CL that eventually landed — CL 764601, merged April 14, 2026 — is nearly identical to chipaca’s original proposal:

// CutLast slices s around the last instance of sep, 
// returning the text before and after sep. 
// The found result reports whether sep appears in s. 
// If sep does not appear in s, CutLast returns s, "", false. 
func CutLast(s, sep string) (before, after string, found bool) { 
    if i := LastIndex(s, sep); i >= 0 { 
        return s[:i], s[i+len(sep):], true 
    } 
    return s, "", false 
}

Eight lines. Fifteen months.

The First Debate: What Should Failure Return?

When the separator isn’t found, should CutLast return s, "", false or "", s, false?

It sounds like a minor detail. It turned into months of back-and-forth.

@earthboundkid argued for "", s, false with a geometric case: as the separator moves leftward through the string, after grows. When there's no separator, after should get everything. There's logic there — Cut puts the original string in before, so CutLast should mirror it in after. A left-right symmetry.

@mateusz834 pushed back: file extensions. CutLast("test", ".") with no dot puts "test" in ext. That's almost never what you want.

Neither side gave ground. Then @rogpeppe ran an audit.

Failure semantics: intuitive vs Go's choice, with rogpeppe's 5 wrong implementations

He searched his own codebase for every call to cutLast that used the returned values without checking found first. He found cases where he'd relied on the "", s, false semantic. Everyone was broken — code that would quietly misbehave when the separator was absent, hidden by the "elegant" intuition.

“I looked at my uses of cutLast where it was using the values regardless of the value of found and found that clearly my intuition was wrong: I was actually using it wrong in all these cases!” — @rogpeppe

Eight thumbs-up. The debate was over.

@adonovan put it precisely: Cut can't simultaneously guarantee "original string is always in the first position" and "Cut + CutLast are left-right inverses." One had to give. Real-world usage, not theoretical elegance, decided which one.

The Second Debate: CutLast or LastCut?

Names in a standard library are permanent. You don’t get to change them.

LastCut aligns with LastIndex — Cut and Index are paired throughout strings, so following that pattern seemed reasonable. @Merovius wrote a systematic counterargument by mapping the entire package into six function families:

  • ContainsX — Contains, ContainsAny, ContainsRune, ContainsFunc
  • HasX — HasPrefix, HasSuffix
  • CutX — Cut, CutPrefix, CutSuffix
  • IndexX — Index, IndexByte, IndexAny, IndexRune, IndexFunc
  • LastIndexX — LastIndex, LastIndexByte, LastIndexAny, LastIndexFunc
  • TrimX — Trim, TrimLeft, TrimRight, TrimSpace, ...
strings package family matrix: where CutLast belongs

The Cut ↔ Index pairing is an outlier, he argued. CutLast belongs in the CutX family alongside CutPrefix and CutSuffix (#42537) — and naming it that way leaves room for CutAny, CutByte, CutLastAny, whatever comes next.

“I would argue CutLast is actually more consistent, as it emphasizes membership of the CutX family. LastCut seems consistent, because Cut and Index are in correspondence, but that correspondence is really an outlier.” — @Merovius

@aclements agreed. CutLast it is.

Three Languages, Three Answers

The same operation exists in other languages, and they all handle the not-found case differently.

Rust’s str::rsplit_once:

let s = "foo:bar:baz"; 
assert_eq!(s.rsplit_once(':'), Some(("foo:bar", "baz"))); 
assert_eq!(s.rsplit_once("::"), None);

Returns Option<(&str, &str)>. Not found means None — no default value, no original string, and the compiler won't let you ignore it.

Python’s str.rpartition:

>>> 'Monty Python'.rpartition(' ') 
('Monty', ' ', 'Python') 
>>> 'Monty Python'.rpartition('-') 
('', '', 'Monty Python')

Not found: ('', '', original_string). The original string mirrors the search direction — partition puts it first, rpartition puts it last.

Go’s strings.CutLast: not found means s, "", false. The original is always in before, search direction be damned.

Table Image
Three languages, three answers to "not found": Go / Rust / Python

Python’s design is probably the most intuitive on first read. It’s also the design rogpeppe had in his head when he wrote five wrong implementations.

The Accidental Early Merge

In April 2026, the proposal still hadn’t been formally accepted when @griesemer accidentally marked the CL as auto-submittable. It merged on April 14.

@earthboundkid noticed. Griesemer apologized and made a call: leave it in. He figured the proposal would pass the next day. If it didn’t, they’d revert.

It passed on April 16. No revert.

The code review itself was almost uneventful. The only substantive Gerrit comment was about semicolons vs. commas in test output — a formatting nitpick, for a change that took 15 months. All the real work had already happened in the issue tracker.

Why 8 Lines Takes 15 Months

Standard library APIs are permanent in a way that few things in software are. Not “permanent until someone files a deprecation PR” permanent — actually permanent. If CutLast had shipped with the "", s, false semantic, that's what it would return in Go 1.30, 1.40, and 1.50. The rogpeppe bug pattern baked in. Billions of programs depending on it.

That’s why you pay the cost upfront. A month of argument in a GitHub issue is cheap compared to carrying a design mistake for twenty years.

The naming debate works the same way. LastCut and CutLast are both defensible in isolation. But the choice isn't just about this one function — it's about what the next person adding CutAny or CutLastByte will reach for. Get the family wrong, and the inconsistency compounds. Fix it later, and you're explaining the exception to developers for a decade.

I keep coming back to rogpeppe’s audit. He had more experience with this function than almost anyone, and he still got it wrong five times. The “intuitive” semantics were the ones hiding his bugs. Fifteen months to avoid that outcome seems like a reasonable trade.

strings.CutLast comes in Go 1.27. By the time you use it, the version of the function in your head will probably be correct. That's not an accident.


References: