Un string de Go es una fila de bytes de solo lectura, casi siempre UTF-8. Cuando lo ves así, que len cuente bytes, que indexar dé un byte, que range dé runes y que un slice parta un carácter en dos, todo tiene sentido.
El texto en Go parece simple hasta que aparece la primera letra con acento o el primer emoji. Entonces len da un número que no esperabas, y un slice de un string imprime un rombo raro.
Este post explica qué guarda de verdad un string de Go, cómo se relacionan con él los bytes y las runes, y los paquetes strings y strconv que vas a usar todos los días. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Un string es una fila de bytes
Un string de Go es una secuencia inmutable de bytes. Go no obliga a que esos bytes sean UTF-8, pero los literales de string en el código fuente de Go son UTF-8, y casi todo el texto que vas a manejar también lo es.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
for _, s := range []string{"hello", "héllo", "日本語", "go🚀"} {
fmt.Println(s, len(s), utf8.RuneCountInString(s))
}
}
Imprime:
hello 5 5
héllo 6 5
日本語 9 3
go🚀 6 3
len cuenta bytes, no caracteres. "hello" es ASCII simple, así que cada letra ocupa un byte y los dos números coinciden. é ocupa dos bytes, así que "héllo" tiene cinco letras y seis bytes. Cada uno de los tres caracteres japoneses ocupa tres bytes. El cohete ocupa cuatro.
utf8.RuneCountInString cuenta runes, que suele estar más cerca de lo que la gente llama «caracteres». Para eso tiene que recorrer todo el string, así que tarda en proporción a la longitud. len es instantáneo, porque la cantidad de bytes se guarda junto con el string.
Explicado como si tuvieras diez años
Imagina un collar de cuentas en un hilo. Cada cuenta es un byte.
Las letras normales del inglés son pequeñas, y cada una ocupa una sola cuenta. Una é necesita dos cuentas. Un carácter japonés necesita tres. Un emoji necesita cuatro. Las cuentas de una misma letra siempre van juntas, y la primera cuenta de cada letra tiene una forma especial, así que puedes saber dónde empieza una letra nueva.
len cuenta cuentas. range, que aparece en un momento, cuenta letras: junta todas las cuentas que van juntas y te da la letra completa.
La versión precisa
Unicode le da a cada carácter un número llamado code point, que se escribe como U+00E9 para é. El nombre que Go le da a un code point es rune, y rune es otro nombre para int32, como mostró la parte sobre valores y tipos.
UTF-8 es la regla para convertir un code point en bytes. Usa de 1 a 4 bytes según qué tan grande sea el número:
| Code points | Bytes | Ejemplos |
|---|---|---|
| U+0000 a U+007F | 1 | ASCII: a, 7, { |
| U+0080 a U+07FF | 2 | é, ñ, griego, cirílico |
| U+0800 a U+FFFF | 3 | 日, la mayoría de las demás escrituras |
| U+10000 a U+10FFFF | 4 | emojis como 🚀 |
El primer byte de cada rune codificada dice cuántos bytes le siguen. Los bytes que siguen empiezan todos con los bits 10, así que nunca se pueden confundir con el inicio de una rune. Por eso Go puede encontrar dónde empieza cada rune sin una tabla de búsqueda.
Dónde falla la analogía: lo que una persona ve como un solo carácter puede ser varios code points, así que «contar letras» con runes no siempre da lo que contaría un lector. La sección sobre emojis, más abajo, muestra un caso real.
Indexar te da un byte
Indexar un string de Go con s[i] devuelve el byte en la posición i, no el i-ésimo carácter:
package main
import "fmt"
func main() {
s := "héllo"
fmt.Println(s[0], s[1], s[2])
fmt.Printf("%c %c\n", s[0], s[1])
fmt.Printf("% x\n", s)
}
Imprime:
104 195 169
h Ã
68 c3 a9 6c 6c 6f
s[0] es 104, el byte de h. Pero é se guarda como dos bytes, c3 y a9, así que s[1] es 195, solo la primera mitad.
Imprimir esa mitad con %c muestra Ã. La primera vez sorprende. %c trata 195 como el code point U+00C3, que resulta ser Ã. Ese byte nunca estuvo pensado para ir solo, así que Go imprimió una letra completamente distinta.
El verbo % x, con un espacio entre % y x, imprime cada byte en hexadecimal. Es la forma más rápida de ver qué guarda de verdad un string.
for range te da runes
Un bucle for range sobre un string decodifica UTF-8 sobre la marcha y te da cada rune junto con el offset en bytes donde empieza:
package main
import "fmt"
func main() {
for i, r := range "hé日🚀!" {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
}
Imprime:
byte 0: h (U+0068)
byte 1: é (U+00E9)
byte 3: 日 (U+65E5)
byte 6: 🚀 (U+1F680)
byte 10: ! (U+0021)
Fíjate cómo saltan los offsets: 0, 1, 3, 6, 10. El índice no cuenta caracteres. Es donde empieza cada rune dentro de los bytes, y la distancia hasta la siguiente es cuántos bytes ocupó esa rune.
Así que usa s[i] cuando te importan los bytes, por ejemplo al parsear protocolos ASCII. Usa range cuando te importan los caracteres.
Rebanar un string puede partir un carácter a la mitad
Rebanar un string, s[low:high], trabaja con posiciones de bytes, igual que indexar. Go no comprueba que el corte caiga en el límite de una rune:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
bad := s[:2]
fmt.Printf("%q len=%d valid=%v\n", bad, len(bad), utf8.ValidString(bad))
for i, r := range bad {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
good := s[:3]
fmt.Printf("%q len=%d valid=%v\n", good, len(good), utf8.ValidString(good))
r := []rune(s)
fmt.Println(string(r[:2]))
}
Imprime:
"h\xc3" len=2 valid=false
byte 0: h (U+0068)
byte 1: � (U+FFFD)
"hé" len=3 valid=true
hé
s[:2] se queda con h y el primer byte de é. Nada provoca un panic y nada te avisa. El resultado simplemente ya no es UTF-8 válido. %q muestra el byte suelto como \xc3.
Cuando range encuentra un byte que no inicia una rune válida, te da U+FFFD, el carácter de reemplazo de Unicode, y avanza un byte. En pantalla, ese es el rombo �. Cuando lo veas en una página web o en un log, en algún punto anterior un string se cortó o se decodificó en el lugar equivocado.
s[:3] corta después de la é completa, así que está bien. Si quieres «los dos primeros caracteres» y no quieres contar bytes, convierte primero a []rune, como hace la última línea.
Las conversiones a []byte y []rune copian
Convertir un string a []byte o []rune te da un slice nuevo con su propia copia de los datos, y por eso puedes cambiarlo:
package main
import "fmt"
func main() {
s := "日本語"
b := []byte(s)
r := []rune(s)
fmt.Println(len(b), b)
fmt.Println(len(r), r)
b[0] = 'X'
r[0] = '月'
fmt.Println(s, string(r))
}
Imprime:
9 [230 151 165 230 156 172 232 170 158]
3 [26085 26412 35486]
日本語 月本語
[]byte(s) guarda los nueve bytes UTF-8. []rune(s) guarda tres code points, cada uno un int32. Cambiar cualquiera de los dos slices deja s intacto, porque cada conversión copió los datos en un array nuevo. Volver a convertir con string(...) copia otra vez.
Esas copias cuestan memoria y tiempo en proporción a la longitud. Para un string corto en un handler de una petición, no importa. Dentro de un bucle sobre un archivo grande sí puede importar, y ahí ayuda el paquete bytes, que aparece cerca del final de este post. Un []rune además ocupa 4 bytes por rune, así que el []rune de un texto casi todo ASCII es unas cuatro veces más grande que el string.
El compilador sí se salta la copia en algunos casos que puede demostrar que son seguros, como string(b) usado solo como clave de un map. No escribas código que dependa de eso.
Los strings no se pueden cambiar
Un string de Go es de solo lectura, así que no puedes asignar a uno de sus bytes:
package main
import "fmt"
func main() {
s := "hello"
s[0] = 'H'
fmt.Println(s)
}
La compilación falla con:
./main.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)
El mensaje no dice «los strings son inmutables». Dice que s[0] no es direccionable, que es la forma en que Go te dice que ahí no hay ninguna caja en la que tengas permiso de escribir.
La inmutabilidad es lo que hace que pasar strings de un lado a otro sea barato. Un valor string es solo un puntero a los bytes y una longitud. Copiarlo, rebanarlo o pasarlo a una función nunca copia los bytes, porque nadie puede cambiarlos a tus espaldas.
Para obtener un string cambiado, construyes uno nuevo:
package main
import "fmt"
func main() {
s := "hello"
t := "H" + s[1:]
b := []byte(s)
b[0] = 'J'
u := string(b)
fmt.Println(s, t, u)
}
Imprime:
hello Hello Jello
t une una primera letra nueva con un slice del string viejo. u pasa por una copia []byte, la cambia y la vuelve a convertir. De cualquiera de las dos formas, s sigue diciendo hello.
Construir strings: + en un bucle y strings.Builder
Unir strings con + en un bucle y usar strings.Builder dan el mismo resultado, pero hacen cantidades de trabajo muy distintas:
package main
import (
"fmt"
"strings"
)
func main() {
words := []string{"strings", "are", "immutable"}
s := ""
for i, w := range words {
if i > 0 {
s += " "
}
s += w
}
var sb strings.Builder
for i, w := range words {
if i > 0 {
sb.WriteByte(' ')
}
sb.WriteString(w)
}
fmt.Fprintf(&sb, " (%d words)", len(words))
fmt.Println(s)
fmt.Println(sb.String())
fmt.Println(strings.Join(words, " "))
}
Imprime:
strings are immutable
strings are immutable (3 words)
strings are immutable
Las tres son correctas. La diferencia está en las copias. Como un string no puede cambiar, cada s += w crea un string completamente nuevo y copia en él todo lo construido hasta ese momento. Construir así un string de 10,000 bytes, un byte a la vez, copia 1 + 2 + … + 10,000 bytes, es decir 50,005,000 bytes, para producir 10,000.
strings.Builder guarda por dentro un []byte que va creciendo, y crece como mostró la parte sobre slices. String() te devuelve ese buffer como string sin volver a copiarlo. Un Builder también funciona como io.Writer, así que fmt.Fprintf puede escribir directo en él.
Para unas pocas piezas, + está bien y se lee mejor. Cuando unes un slice que ya tienes, strings.Join es lo más corto. En un bucle con muchas piezas, usa un Builder.
Un recorrido por el paquete strings
El paquete strings cubre la mayoría de las tareas con texto para las que de otro modo escribirías bucles. Estas son las que vas a usar primero, sobre algo que parece una línea de una petición HTTP:
package main
import (
"fmt"
"strings"
)
func main() {
line := " GET /users/42 HTTP/1.1 "
clean := strings.TrimSpace(line)
fmt.Printf("%q\n", clean)
fmt.Println(strings.Contains(clean, "/users"), strings.HasPrefix(clean, "GET "))
fmt.Printf("%q\n", strings.Split("a,b,,c", ","))
fmt.Printf("%q\n", strings.Fields(line))
parts := strings.Fields(clean)
fmt.Println(strings.Join(parts, " | "))
fmt.Println(strings.Replace("a-b-c", "-", "+", 1), strings.ReplaceAll("a-b-c", "-", "+"))
fmt.Println(strings.ToUpper("héllo, 日本"))
key, value, found := strings.Cut("Content-Type: text/html", ": ")
fmt.Printf("%q %q %v\n", key, value, found)
_, _, found = strings.Cut("no colon here", ": ")
fmt.Println(found)
}
Imprime:
"GET /users/42 HTTP/1.1"
true true
["a" "b" "" "c"]
["GET" "/users/42" "HTTP/1.1"]
GET | /users/42 | HTTP/1.1
a+b-c a+b+c
HÉLLO, 日本
"Content-Type" "text/html" true
false
Vale la pena conocer algunos detalles:
TrimSpacequita los espacios en blanco solo de los dos extremos. Los espacios del medio se quedan.Splitconserva las piezas vacías."a,b,,c"tiene un string vacío entre las dos comas.Fieldssepara por cualquier secuencia de espacios en blanco y nunca devuelve piezas vacías, así que es la mejor opción para palabras.Replacerecibe una cantidad.-1significa todas, yReplaceAlles la forma legible de decir eso.ToUpperentiende Unicode, así queése convirtió enÉ. El japonés no tiene mayúsculas, así que quedó igual.Cut, agregado en Go 1.18, separa alrededor de la primera coincidencia y te dice si encontró una. Reemplaza mucha aritmética conIndexy muchas llamadas aSplitde dos elementos.
Ninguna de estas funciones cambia line ni clean. Cada función que «modifica» un string devuelve uno nuevo.
Convertir números con strconv
El paquete strconv convierte entre strings y números, y sus funciones de parseo devuelven un error, porque el texto que viene de fuera de tu programa muchas veces no es un número:
package main
import (
"fmt"
"strconv"
)
func main() {
s := strconv.Itoa(42)
fmt.Printf("%q\n", s)
n, err := strconv.Atoi("123")
fmt.Println(n, err)
n, err = strconv.Atoi("12a")
fmt.Println(n, err)
f, err := strconv.ParseFloat("3.25", 64)
fmt.Println(f, err)
fmt.Println(strconv.Quote("tab\there, \"quotes\", é"))
fmt.Println(strconv.QuoteToASCII("é🚀"))
}
Imprime:
"42"
123 <nil>
0 strconv.Atoi: parsing "12a": invalid syntax
3.25 <nil>
"tab\there, \"quotes\", é"
"\u00e9\U0001f680"
Itoa convierte un int en su texto decimal. Atoi hace lo contrario. Con una entrada inválida devuelve 0 y un error que nombra la función y la entrada, lo que ya sirve tal cual como línea de log. Revisa ese error cada vez que parsees un parámetro de consulta o un campo de un formulario. La parte sobre errores explica qué hacer con él.
ParseFloat recibe el tamaño en bits, 64 para un float64. Quote envuelve un string entre comillas dobles y escapa lo que haga falta, igual que %q. QuoteToASCII además escapa todo lo que está fuera de ASCII.
Una trampa: si n es un int que vale 42, string(n) no da "42". Trata el número como el code point U+002A, que es *. El compilador lo acepta, pero go vet te detiene con conversion from int to string yields a string of one rune, not a string of digits. Lo que querías era strconv.Itoa.
Literales de string raw
Un literal de string raw se escribe entre backticks, y Go toma todo lo que hay adentro exactamente como está: las barras invertidas siguen siendo barras invertidas, y los saltos de línea siguen siendo saltos de línea.
package main
import (
"fmt"
"regexp"
)
func main() {
interpreted := "C:\\temp\\new\n"
raw := `C:\temp\new\n`
fmt.Print(interpreted)
fmt.Println(raw)
re := regexp.MustCompile(`^\d{3}-\d{4}$`)
fmt.Println(re.MatchString("555-1234"))
usage := `Usage:
tool [flags]
tool help`
fmt.Println(usage)
}
Imprime:
C:\temp\new
C:\temp\new\n
true
Usage:
tool [flags]
tool help
En el string entre comillas dobles, \\ significa una barra invertida y \n significa un salto de línea. En el string entre backticks, \n es solo una barra invertida y una n.
Usa backticks cuando el texto está lleno de barras invertidas o ocupa varias líneas: expresiones regulares, rutas de Windows, SQL, JSON en un test o un mensaje de uso. Lo único que un string raw no puede contener es un backtick.
Cuando un carácter es más de una rune
Una rune es un code point, pero lo que una persona ve como un solo carácter puede estar formado por varios. Los emojis con tono de piel y las letras con una marca de acento separada son los casos comunes:
package main
import "fmt"
func main() {
wave := "👋🏽"
fmt.Println(wave, len(wave), len([]rune(wave)))
for i, r := range wave {
fmt.Printf("byte %d: U+%X\n", i, r)
}
composed := "caf\u00e9"
decomposed := "cafe\u0301"
fmt.Println(composed, decomposed, composed == decomposed)
fmt.Println(len([]rune(composed)), len([]rune(decomposed)))
}
Imprime:
👋🏽 8 2
byte 0: U+1F44B
byte 4: U+1F3FD
café café false
4 5
La mano que saluda es un solo símbolo en pantalla, pero son dos runes: la mano, U+1F44B, y un modificador de tono de piel, U+1F3FD. Cada una ocupa cuatro bytes, así que len es 8 y []rune tiene longitud 2.
Los dos cafés se ven idénticos, pero no son iguales. El primero usa el code point único é. El segundo es una e simple seguida de U+0301, un acento combinable que se coloca sobre la letra anterior. Por eso uno tiene cuatro runes y el otro tiene cinco.
Aquí termina lo que la biblioteca estándar hace por ti. Contar caracteres como los ve un lector, o tratar los dos cafés como iguales, requiere normalización Unicode y segmentación en grafemas. Ninguna de las dos está en la biblioteca estándar. La normalización está en el paquete golang.org/x/text/unicode/norm, que mantiene el equipo de Go fuera de la biblioteca estándar. Para la mayoría de los programas, saber que las runes no son exactamente caracteres basta para evitar el bug.
El paquete bytes es el espejo de strings
El paquete bytes tiene las mismas funciones que strings, pero para []byte, así que puedes trabajar con datos de un archivo o de una conexión de red sin convertirlos a string y de vuelta:
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
data := []byte(" hello, world ")
fmt.Printf("%q\n", bytes.TrimSpace(data))
fmt.Println(bytes.Contains(data, []byte("world")), strings.Contains(string(data), "world"))
fmt.Printf("%q\n", bytes.ToUpper(data))
var buf bytes.Buffer
buf.WriteString("status: ")
buf.WriteString("ok")
fmt.Println(buf.String(), buf.Len())
}
Imprime:
"hello, world"
true true
" HELLO, WORLD "
status: ok 10
bytes.TrimSpace, bytes.Contains y bytes.ToUpper hacen lo mismo que sus gemelas de strings. bytes.Buffer es como strings.Builder, pero también puedes leer de él. Casi toda la E/S en Go trabaja con []byte, así que vas a volver a ver este paquete cuando la serie llegue a los handlers HTTP.
Qué recordar
- Un string es una fila inmutable de bytes, casi siempre UTF-8.
lencuenta bytes, yutf8.RuneCountInStringcuenta runes. s[i]ys[low:high]trabajan con bytes. Un slice puede partir una rune a la mitad, y el byte roto aparece como�.for rangesobre un string decodifica runes y te da el offset en bytes donde empieza cada una.[]byte(s),[]rune(s)ystring(...)copian los datos cada uno.- No puedes cambiar un string. Construye uno nuevo, y usa
strings.Buildercuando agregues muchas piezas en un bucle. - Parsea números con
strconvy revisa el error.string(n)sobre unintda una rune, no dígitos. - Una rune es un code point, no siempre un carácter como lo ve un lector. Un emoji con tono de piel son dos runes.
Los strings de Go guardan bytes. Los caracteres son algo que decodificas a partir de ellos.