strings.Map applies one function to every rune in a UTF-8 string and builds a string from the returned runes. A mapping function can preserve a rune, replace it, or remove it entirely. That makes the API a compact fit for transformations whose rule is naturally expressed one Unicode code point at a time.

mapped := strings.Map(func(r rune) rune {
    if r == '_' {
        return '-'
    }
    return r
}, input)

The operation is rune-oriented rather than byte-oriented. ASCII input still follows the same contract, but multibyte UTF-8 sequences arrive at the callback as decoded rune values.

A negative return value removes a rune

The mapping callback has one special result: any negative rune value causes the current input rune to be omitted from the output. No replacement bytes are emitted.

That permits filtering and transformation in the same pass. For example, an identifier formatter can discard spaces while converting underscores to hyphens:

package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "api_v2 status"

    s = strings.Map(func(r rune) rune {
        switch r {
        case ' ':
            return -1
        case '_':
            return '-'
        default:
            return r
        }
    }, s)

    fmt.Println(s)
}

The resulting string is api-v2status. Returning 0 is different: zero is a valid rune, so it inserts U+0000 into the result. Deletion requires a negative value.

The callback receives Unicode code points

A Go string is a byte sequence, commonly containing UTF-8 text. strings.Map ranges over that text as runes. A callback can therefore use predicates and conversions from the unicode package without manually decoding UTF-8.

clean := strings.Map(func(r rune) rune {
    if unicode.IsSpace(r) {
        return ' '
    }
    return unicode.ToLower(r)
}, input)

This replaces every rune classified as Unicode whitespace with an ASCII space and applies Unicode lowercase conversion to the remaining runes.

Rune-level processing also defines the boundary of the operation. A mapping function sees one code point at a time, not grapheme clusters. A visible character can consist of multiple code points, such as a base letter followed by a combining mark. Rules that depend on complete user-perceived characters need a representation with that higher-level segmentation.

One input rune produces at most one output rune

The callback signature is func(rune) rune, so a single input rune cannot directly expand into several output runes. Replacing & with the three-character string and, for example, does not fit this contract.

That distinction separates strings.Map from APIs that replace substrings. strings.ReplaceAll handles fixed substring substitution, while strings.NewReplacer can express several fixed replacements. A builder or another explicit scan is appropriate when replacement text has variable length or depends on surrounding input.

strings.Map is most direct when the transformation is local to each rune:

  • case conversion;
  • punctuation normalization;
  • deletion of selected rune classes;
  • substitution of one delimiter rune for another.

Context-sensitive parsing is a different operation. If treatment of a rune depends on previous tokens, quoting state, or a multi-rune delimiter, a stateful scanner keeps those rules visible.

The output may use a different byte width

One rune does not imply one byte. Mapping an ASCII rune to a non-ASCII rune can increase the encoded byte length, while mapping a multibyte rune to ASCII can reduce it.

s := strings.Map(func(r rune) rune {
    if r == 'a' {
        return '界'
    }
    return r
}, "cat")

The input contains three bytes. The replacement rune occupies three bytes in UTF-8, so the resulting string occupies more bytes than the source even though the rune count is unchanged.

Code that applies a byte-size limit after transformation should check the resulting string, not infer its size from the input rune count.

Invalid UTF-8 is decoded during traversal

Because the function processes runes, malformed UTF-8 bytes are encountered through Go’s normal UTF-8 decoding behavior as utf8.RuneError. The original invalid byte sequence is not exposed to the callback as raw bytes.

That property matters when byte preservation is required. A protocol field, binary payload, or text container that must retain malformed input exactly should not use a rune transformation as a transparent byte filter. A byte-oriented pass preserves distinctions that rune decoding can erase.

For ordinary UTF-8 text, the rune contract is usually the useful one: the callback works with code points instead of encoded byte fragments.

Identity mappings keep the rule narrow

A mapping function commonly returns the original rune in its default branch:

normalized := strings.Map(func(r rune) rune {
    switch r {
    case '–', '—':
        return '-'
    default:
        return r
    }
}, text)

This shape makes the transformation explicit: only the listed runes change. It also avoids broad normalization claims. Mapping selected dash characters to ASCII hyphen is not Unicode normalization, and case conversion of individual runes is not a substitute for locale-sensitive text processing.

The API is deliberately small. Its useful boundary is equally clear: each decoded rune is considered independently and contributes zero or one rune to a newly produced string. When a text rule fits that model, strings.Map expresses the mechanism without introducing tokenization or mutable indexing. When the rule crosses rune boundaries, the code should expose that extra state instead of hiding it inside a per-rune callback.