Преобразование int в string в Go: все способы с примерами

• обновлено • 10 мин чтения

В 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)
AppendInt12.3600
FormatUint23.7081
FormatInt23.9781
Itoa24.8081
FmtSprint78.4281
FmtSprintf82.7881

Что из этого следует:

Отдельно стоит отметить изменение с момента первой публикации статьи. В замерах 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 выдаёт ровно тот же текст предупреждения.

Поменялось окружение вокруг этой задачи:

То есть код из этой статьи, написанный в 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 ./...        # перепишет код на современные конструкции (изменяет файлы)

Итог

Держите в голове три правила, и вопрос закрыт:

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". Цифры больше девяти записываются строчными латинскими буквами.

Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: