В Go преобразовать int в string можно с помощью функции strconv.Itoa() из стандартной библиотеки.
Это самый простой и быстрый вариант для int. Но в реальном коде хватает int64 и uint64, а иногда число надо встроить в строку с форматированием.
Ниже все рабочие способы, бенчмарки и одна частая ошибка, которую компилятор пропускает.
Статья обновлена в сентябре 2026 года. Все примеры и бенчмарки проверены на Go 1.27.1.
Оптимальный
strconv.Itoa эквивалентна FormatInt(int64(i), 10).
num := 42
str := strconv.Itoa(num) // "42"
Хороший №1
fmt.Sprint форматирует операнды, используя форматы по умолчанию, и возвращает полученную строку. Пробелы добавляются между операндами в том случае, если ни один из них не является строкой.
num := 42
str := fmt.Sprint(num) // "42"
Хороший №2
fmt.Sprintf форматирует в соответствии со спецификатором формата и возвращает полученную строку.
num := 42
str := fmt.Sprintf("%d", num) // "42"
Правильный для int64
strconv.FormatInt возвращает строковое представление числа i в заданной системе счисления (где 2 <= основание <= 36). Для представления значений цифр >= 10 используются строчные латинские буквы от ‘a’ до ‘z’.
var num int64 = 42
str := strconv.FormatInt(num, 10) // Base 10: "42"
Правильный для uint64
strconv.FormatUint возвращает строковое представление i в указанной системе счисления, для 2 <= основание <= 36. Для записи цифр со значениями >= 10 используются строчные буквы от ‘a’ до ‘z’.
var num uint64 = 42
str := strconv.FormatUint(num, 10) // "42"
Быстрый: strconv.AppendInt
Все способы выше возвращают новую строку, а значит выделяют память в куче на каждый вызов. Если вы конвертируете числа в горячем цикле, этого можно избежать.
strconv.AppendInt дописывает число в существующий байтовый слайс и ничего не аллоцирует, пока хватает ёмкости буфера:
buf := make([]byte, 0, 32)
for _, id := range ids {
buf = buf[:0] // сбрасываем длину, ёмкость остаётся
buf = strconv.AppendInt(buf, id, 10)
w.Write(buf)
}
В бенчмарках ниже этот вариант вдвое быстрее strconv.Itoa именно потому, что не вызывает аллокатор.
Есть и аналогичные strconv.AppendUint, strconv.AppendFloat и strconv.AppendQuote для других типов, принцип у них тот же.
Но это микрооптимизация. Она оправдана в немногих местах: сериализаторах, логгерах и других горячих путях, где вы уже видите аллокации в профиле. В обычном коде читаемость strconv.Itoa важнее производительности.
Ошибочный метод
num := 42
str := string(num) // "*", а не "42"
code := 65
char := string(code) // "A", а не "65"
Почему это ошибка?
Код скомпилируется и запустится, но вместо строкового представления числа ("42") вернёт символ с кодом Unicode 42 (знак звёздочки/умножения '*').
Вместо строкового представления числа ("65") вернёт символ с кодом Unicode 65 (буква 'A').
Потому что string(num) интерпретирует число как Unicode-код, а не преобразует его в цифры.
Как обнаружить?
go vet предупредит о потенциально неверном использовании string() с числовым аргументом:
$ go vet ./...
main.go:9:9: conversion from int to string yields a string of one rune, not a string of digits
Проверка называется stringintconv и входит в набор, который go test запускает автоматически. Отдельно включать её не нужно.
Если вам действительно нужен символ по коду, приведите число к rune явно:
num := 65
str := string(rune(num)) // "A", и go vet молчит
Ловушка: адреса через fmt.Sprintf
Есть отдельный случай, где преобразование числа в строку ломает работающий код.
Порт является числом, и собрать из хоста и порта адрес через fmt.Sprintf кажется естественным:
addr := fmt.Sprintf("%s:%d", host, port) // сломается на IPv6
conn, err := net.Dial("tcp", addr)
Для IPv4 это работает, но не для IPv6. Адрес вида ::1 сам содержит двоеточия, и результат ::1:8080 разобрать невозможно. Правильный вариант:
addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := net.Dial("tcp", addr)
Начиная с Go 1.25 такие места ловит анализатор hostport в составе go vet. До этого ошибку находили уже в проде, когда сервис впервые попадал в IPv6-окружение.
Бенчмарки
Бенчмарк из первой версии статьи был написан в старом стиле for i := 0; i < b.N; i++.
Начиная с Go 1.24 вместо этого используется b.Loop(). Он сам решает, сколько итераций нужно, и защищает вызовы внутри от удаления оптимизатором (DCE, Dead Code Elimination).
В Go 1.26 у b.Loop() убрали побочный эффект, из-за которого тело цикла не инлайнилось и бенчмарк мог показывать лишние аллокации. С этим исправлением на b.Loop() можно переводить любые бенчмарки.
В старой версии замера счётчик бежал от нуля, и в результат попадала смесь однозначных и семизначных чисел. Числа меньше 100 strconv отдаёт из готовой таблицы без аллокации, поэтому средние цифры получались смазанными. Здесь конвертируется одно фиксированное семизначное число, так результат воспроизводим.
package main
import (
"fmt"
"strconv"
"testing"
)
// Глобальные переменные, чтобы компилятор не выбросил результат.
var (
sink string
sinkB []byte
)
const num = 1234567
func BenchmarkItoa(b *testing.B) {
for b.Loop() {
sink = strconv.Itoa(num)
}
}
func BenchmarkFmtSprint(b *testing.B) {
for b.Loop() {
sink = fmt.Sprint(num)
}
}
func BenchmarkFmtSprintf(b *testing.B) {
for b.Loop() {
sink = fmt.Sprintf("%d", num)
}
}
func BenchmarkFormatInt(b *testing.B) {
for b.Loop() {
sink = strconv.FormatInt(int64(num), 10)
}
}
func BenchmarkFormatUint(b *testing.B) {
for b.Loop() {
sink = strconv.FormatUint(uint64(num), 10)
}
}
func BenchmarkAppendInt(b *testing.B) {
buf := make([]byte, 0, 32)
for b.Loop() {
sinkB = strconv.AppendInt(buf[:0], num, 10)
}
}
Просто запустить go test -bench=. недостаточно. На ноутбуке с governor schedutil частота гуляет от 1.4 до 4.0 ГГц, и разброс замеров у меня доходил до ±14%. На таком фоне можно «увидеть» любой эффект, которого нет.
Поэтому собираем тестовый бинарник заранее, прижимаем его к одному ядру и выключаем параллелизм. Двадцать прогонов сводим через benchstat, чтобы увидеть разброс:
$ go test -c -o bench .
$ taskset -c 4 ./bench -test.bench=. -test.benchtime=1s # прогрев, результат выбрасываем
$ taskset -c 4 env GOMAXPROCS=1 ./bench \
-test.bench=. -test.benchmem -test.benchtime=1s -test.count=20 > res.txt
$ go run golang.org/x/perf/cmd/benchstat@latest res.txt
Разброс падает с ±14% до ±0%:
goos: linux
goarch: amd64
pkg: intbench
cpu: AMD Ryzen 5 PRO 4650U with Radeon Graphics
│ sec/op │
Itoa 24.80n ± 0%
FormatInt 23.97n ± 0%
FormatUint 23.70n ± 0%
FmtSprint 78.42n ± 0%
FmtSprintf 82.78n ± 0%
AppendInt 12.36n ± 0%
Сводная таблица:
| Метод | Время (ns/op) | Память (B/op) | Аллокации (allocs/op) |
|---|---|---|---|
AppendInt | 12.36 | 0 | 0 |
FormatUint | 23.70 | 8 | 1 |
FormatInt | 23.97 | 8 | 1 |
Itoa | 24.80 | 8 | 1 |
FmtSprint | 78.42 | 8 | 1 |
FmtSprintf | 82.78 | 8 | 1 |
Что из этого следует:
strconv.Itoa,FormatIntиFormatUintидут вровень. Разница между ними меньше 5%, на практике она незаметна. Выбирать между ними по скорости смысла нет, выбирайте по типу аргумента.fmt.Sprintиfmt.Sprintfмедленнее примерно втрое. Они разбирают формат и работают черезinterface{}, за это и платят.strconv.AppendIntбыстрее остальных вдвое и не аллоцирует вовсе.
Отдельно стоит отметить изменение с момента первой публикации статьи. В замерах 2025 года fmt.Sprint и fmt.Sprintf стоили 16 B/op и две аллокации, а разрыв с strconv был почти трёхкратным. Сейчас у них одна аллокация и 8 B/op, то есть по памяти fmt догнал strconv. По времени разрыв остался прежним, около трёх раз: плата за разбор формата и работу через interface{} никуда не делась. Команда Go планомерно вычищает аллокации из fmt: в Go 1.26, например, fmt.Errorf для строк без форматирования догнали по аллокациям errors.New.
Подробнее о написании и анализе бенчмарков читайте в статье Бенчмарки и оптимизация в Go, а про сам testing — в статье Тестирование в Go.
Что изменилось в языке с 2025 года
Короткий ответ: в самой конвертации int в string ничего.
Сигнатуры strconv.Itoa, FormatInt, FormatUint не менялись, семантика string(i) тоже прежняя, и go vet выдаёт ровно тот же текст предупреждения.
Поменялось окружение вокруг этой задачи:
- Go 1.24: появился
testing.B.Loop(), новый способ писать бенчмарки вместоfor i := 0; i < b.N; i++. - Go 1.25: в
go vetдобавили анализаторhostport, который ловит сборку адресов черезfmt.Sprintf("%s:%d", host, port). - Go 1.26:
b.Loop()перестал мешать инлайнингу тела цикла, из-за чего раньше бенчмарки могли показывать лишние аллокации.fmt.Errorfдля неформатированных строк стал аллоцировать меньше. Ещё переписалиgo fix: теперь это набор анализаторов, среди которых естьfmtappendf, заменяющий[]byte(fmt.Sprintf(...))наfmt.Appendf. - Go 1.27: про
strconv,fmtиtestingв релиз-ноутах ни слова. Зато в разделе Runtime компилятор научился вызывать аллокатор, специализированный по размеру объекта. Из-за этого конвертация ускорилась, подробности в следующем разделе.go testстал по умолчанию запускать проверкуstdversion: она ругается на использование символов стандартной библиотеки, которые новее версии изgo.mod.
То есть код из этой статьи, написанный в 2025 году, продолжает работать без правок. Обновить стоит только бенчмарки: перевести их на b.Loop().
Почему на Go 1.27 стало быстрее
Здесь меня ждал сюрприз. Релиз-ноуты 1.27 про strconv молчат, реализация в internal/strconv/itoa.go в 1.26 и 1.27 совпадает побайтово. А замеры показывают уверенное ускорение.
Первый прогон я сделал обычным go test и получил «1.27 быстрее на 16%». Второй прогон, с переставленными местами версиями, показал уже 1.4%. Оба результата были мусором: на непрогретой машине с плавающей частотой первая измеренная версия всегда в проигрыше.
Честный ответ дала такая схема: бинарники обоих тулчейнов собираются заранее, прогоняются по очереди на одном ядре, порядок внутри блока симметричный (1.26, 1.27, 1.27, 1.26), 20 замеров на версию.
│ go1.26 │ go1.27 │
│ sec/op │ sec/op vs base │
Itoa 28.20n ± 1% 24.82n ± 1% -12.00% (p=0.000 n=20)
FormatInt 29.26n ± 1% 24.02n ± 1% -17.92% (p=0.000 n=20)
FormatUint 28.51n ± 2% 23.71n ± 0% -16.84% (p=0.000 n=20)
FmtSprint 83.71n ± 1% 78.40n ± 1% -6.34% (p=0.000 n=20)
FmtSprintf 87.00n ± 1% 82.76n ± 1% -4.87% (p=0.000 n=20)
AppendInt 12.58n ± 3% 12.36n ± 0% -1.71% (p=0.002 n=20)
Обратите внимание на последнюю строку. Все методы, которые выделяют память, ускорились на 5–18%. Единственный метод без аллокаций, AppendInt, не сдвинулся почти совсем. Отсюда и гипотеза: дело не в strconv, а в аллокаторе.
Проверить её просто. Берём чистую аллокацию без всякой конвертации и контрольный бенчмарк без аллокаций вообще:
var (
sinkP *[8]byte
sinkP24 *[24]byte
sinkInt int
)
func BenchmarkPureAlloc8(b *testing.B) {
for b.Loop() {
sinkP = new([8]byte)
}
}
func BenchmarkPureAlloc24(b *testing.B) {
for b.Loop() {
sinkP24 = new([24]byte)
}
}
func BenchmarkNoAlloc(b *testing.B) {
x := 0
for b.Loop() {
x = x*31 + 7
}
sinkInt = x
}
│ go1.26 │ go1.27 │
│ sec/op │ sec/op vs base │
PureAlloc8 13.980n ± 7% 8.095n ± 1% -42.10% (p=0.000 n=16)
PureAlloc24 26.09n ± 2% 19.85n ± 0% -23.92% (p=0.000 n=16)
NoAlloc 0.7941n ± 71% 0.7943n ± 36% ~ (p=0.596 n=16)
Аллокация восьми байт в Go 1.27 стала дешевле на 42%, аллокация двадцати четырёх на 24%, а код без аллокаций не изменился вовсе. Этим объясняется весь выигрыш strconv. Кроме арифметики Itoa только выделяет строку.
Позже я нашёл подтверждение в релиз-ноутах 1.27, в разделе Runtime: компилятор теперь вызывает специализированные по размеру функции аллокации, и мелкие (до 80 байт) аллокации дешевеют до 30%. У меня на 8 байтах вышло даже больше. Отключить оптимизацию можно через GOEXPERIMENT=nosizespecializedmalloc, если хотите проверить причину сами.
Отсюда два вывода. Первый: ускорение в 12–18% бесплатно получит любой код, который часто выделяет мелкие объекты. Второй: прежде чем верить своему бенчмарку, прогоните версии в обратном порядке. Если разница исчезла или сменила знак, вы измеряли не код, а нагрев ноутбука.
Проверить свой проект на устаревшие конструкции можно так:
$ go fix -diff ./... # покажет патч, файлы не меняет
$ go fix ./... # перепишет код на современные конструкции (изменяет файлы)
Итог
Держите в голове три правила, и вопрос закрыт:
- По умолчанию
strconv.Itoa. Дляintон и читается проще всего, и работает быстро. - Для других целочисленных типов
FormatIntиFormatUint. Приводите аргумент кint64илиuint64. - В горячем цикле
AppendIntс переиспользуемым буфером. Выигрыш вдвое и ни одной аллокации, но беритесь за него только после профилирования.
fmt.Sprint и fmt.Sprintf уместны, когда число входит в более сложную строку. Ради одной лишь конвертации числа звать fmt не стоит: вы платите втрое дороже за форматирование, которое не используете.
И главное: не пишите string(i). Это единственный способ из перечисленных, который молча даёт неверный результат.
FAQ
Как преобразовать int в строку в Golang?
strconv.Itoa() или fmt.Sprintf(). Конкретные примеры можно посмотреть выше.Как конвертировать int32 в строку в Golang?
int32 к int64 типу и только после проводить конвертацию.
Например, вот так: strconv.FormatInt(int64(v), 10).Как конвертировать int64 в строку в Golang?
strconv.FormatInt(v, 10), так как Itoa работает только с int.Почему при конвертации int в строку получается один символ (rune)?
string(i) напрямую. Это интерпретирует число как Unicode-код. Используйте strconv или fmt.Как конвертировать uint64 в строку в Golang?
strconv.FormatUint(v, 10), функция принимает uint64 напрямую.Как конвертировать число в строку без аллокаций?
strconv.AppendInt(buf, v, 10) с переиспользуемым буфером. Пока ёмкости buf хватает, аллокаций не будет вовсе.
Возвращаемый []byte можно писать в io.Writer напрямую, не превращая в строку.Как перевести число в двоичную или шестнадцатеричную систему?
FormatInt и FormatUint передаётся основание системы счисления, от 2 до 36:
strconv.FormatInt(42, 2) даст "101010", а strconv.FormatInt(255, 16) даст "ff".
Цифры больше девяти записываются строчными латинскими буквами.Теги: