skip to content

Why does converting an int with string(n) in Go not produce its decimal digits?

level: juniorimportance: should knowfreq 66%

answer

  1. conversion is not formatting
  2. string() thinks in code points
  3. 65 is a letter here, not two digits
  4. the strconv function named after C's itoa

basics

~20 s

A conversion from an integer to string treats the number as a Unicode code point, so string(65) is the one-character string A, not 65. Use strconv.Itoa for the decimal text, or strconv.FormatInt for another base.

solid answer

~40 s

`string(n)` is a *conversion*, not formatting. Go's conversion rules say an integer converted to a string yields the UTF-8 encoding of that code point, so with `n := 65` you get `"A"`, and an integer that is not a valid code point, such as a negative one, silently becomes the replacement character U+FFFD. The conversion exists so `string(rune)` works; writing it directly on an `int` is almost always a bug, and `go vet`'s `stringintconv` check flags it, including on the subset of vet that runs during `go test`. For the decimal text use `strconv.Itoa(n)`, or `strconv.FormatInt(int64(n), base)` for bases 2 through 36 and `strconv.FormatUint` for unsigned values. The reverse direction is `strconv.Atoi` or `strconv.ParseInt`.

code

go · 4 lines
go
n := 65
a := string(rune(n))                // "A": a code point
d := strconv.Itoa(n)                // "65": the decimal digits
h := strconv.FormatInt(int64(n), 16) // "41": base 16

go deeper

for a junior

Remember two facts: string() on an integer gives you the character with that code number, and strconv.Itoa gives you the digits. Being able to say what string(rune(65)) evaluates to is usually the whole question.

for a middle

Explain the mechanism, not just the fix. Integer-to-string is a language conversion producing the UTF-8 encoding of a code point, out-of-range values silently become U+FFFD, and go vet's stringintconv check reports the pattern.

for a senior

Demonstrate how you keep the bug out of a codebase: vet running as part of go test, an explicit string(rune(x)) at the places where a code point genuinely is meant, and a review habit of asking which of the two operations the author intended.

for a principal

Frame it as a class of silent type-level mistakes rather than one gotcha. Decide which analyzers are mandatory in the pipeline, and make sure the ones that catch silent data corruption run everywhere, including generated code and packages people exclude from linting.

## Conversion is not formatting Go has two different operations that both turn a number into text, and they are unrelated: - **Conversion**, written `string(x)`, is a type-level operation defined by the language spec. - **Formatting**, `strconv.Itoa(x)` or the `fmt` package, is a library operation that renders the number's decimal (or other base) representation. The language spec says that converting a signed or unsigned integer value to a string type yields "a string containing the UTF-8 representation of the integer" interpreted as a Unicode code point. So: ```go n := 65 string(rune(n)) // "A" - code point 65 is the letter A strconv.Itoa(n) // "65" - the decimal digits ``` Both are one line, both compile, and they mean entirely different things. Engineers arriving from Java, Python, C# or JavaScript reach for `string(n)` because `String.valueOf(n)`, `str(n)` and `n.toString()` all do the decimal thing. In Go that instinct produces mojibake instead of digits. ## What happens at the edges - `string(rune(0x4E16))` gives a three-byte UTF-8 string, one character. The result length is the *encoded byte* length, not 1. - Converting a negative integer, or one above the maximum code point U+10FFFF, produces the single replacement character U+FFFD. There is no error and no panic - the value is simply lost. - `string(byteSlice)` and `string(runeSlice)` are different conversions again and both are legitimate and common. Because the bad case is silent, this bug survives review and testing until someone reads a log line or a database row full of unexpected characters. ## The tooling catches it `go vet`'s `stringintconv` analyzer reports conversions of the form `string(x)` where `x` has an integer type other than `byte` or `rune`, and suggests either `string(rune(x))` if a code point was meant, or `strconv.Itoa(x)` if digits were meant. `stringintconv` is part of the limited vet subset that `go test` runs automatically, so in a project with any tests at all this mistake usually fails the build before it ships. Do not rely on that alone in generated code or in packages excluded from vet. ## The right tools for the digits ```go strconv.Itoa(n) // int -> decimal string strconv.FormatInt(int64(n), 10) // int64 -> decimal string strconv.FormatInt(int64(n), 16) // int64 -> "41" for 65, lowercase hex strconv.FormatUint(uint64(u), 2) // uint64 -> binary string strconv.AppendInt(buf, int64(n), 10) // append the digits to an existing []byte ``` `Itoa` is exactly `FormatInt(int64(i), 10)`, kept as a separate function because base-10 conversion of an `int` is overwhelmingly the common case. `FormatInt` accepts any base from 2 to 36 and uses the lowercase letters `a` to `z` for digit values 10 and above; it panics only if the base is outside that range. Negative values get a leading `-` sign in every base, so `strconv.FormatInt(-42, 2)` is `"-101010"` rather than a two's-complement bit pattern. ## Round-tripping The pairs line up neatly, which is worth saying in an interview because it shows you see the package as a family rather than a bag of functions: | direction | int | other bases / widths | bool | | --- | --- | --- | --- | | text to value | `Atoi` | `ParseInt`, `ParseUint` | `ParseBool` | | value to text | `Itoa` | `FormatInt`, `FormatUint` | `FormatBool` | `strconv.Itoa(strconv.Atoi(s))` is not valid Go because of the error result, but conceptually the round trip is exact for every value an `int` can hold - unlike the same trip through a floating-point type. ## When a code point really is what you want `string(rune(x))` is correct when `x` is a code point: building a single-character string from a rune you computed, or writing a delimiter as a number. Writing the conversion through an explicit `rune` also documents the intent for the next reader and silences vet. If you find yourself converting an integer to a string to use as a map key or an identifier, you want `strconv.Itoa`. ## How to answer State the rule first - integer to string is a code-point conversion, not a decimal rendering - then give the fix, `strconv.Itoa`, then mention that `go vet` catches it. A candidate who can also say what happens to an out-of-range integer, that it becomes U+FFFD silently, is showing they have read the conversion rules rather than memorised a gotcha.

  • What happens when you convert an integer that is not a valid Unicode code point, such as string(rune(-1))?
    You get the single replacement character U+FFFD, encoded as three bytes. There is no error and no panic, which is what makes the mistake so quiet: negative values and anything above U+10FFFF all collapse to the same character, and the original number is unrecoverable from the result.
  • How do you render an int in hexadecimal without using fmt?
    `strconv.FormatInt(int64(n), 16)`, which gives lowercase digits and a leading `-` for negatives, or `strconv.FormatUint(uint64(u), 16)` for unsigned values. The base may be anything from 2 to 36; outside that range FormatInt panics. `strconv.AppendInt` does the same into an existing byte slice.
  • Which tool catches string(intVar) before it reaches production?
    `go vet`'s `stringintconv` analyzer. It reports integer-to-string conversions where the operand is not `byte` or `rune` and suggests both fixes. It is one of the checks in the limited vet subset that `go test` runs automatically, so it usually fails a normal test run rather than waiting for a separate lint step.

string(n) looks up character number n in a chart. strconv.Itoa writes the number out in digits. Asking for character 65 and expecting to see the text 65 is the whole confusion.

saying these in an interview costs you the question

  • Says string(n) formats the number like Sprintf does
  • Believes string(65) is a compile error
  • Thinks string(n) and strconv.Itoa(n) are interchangeable
  • Uses string(n) to build a numeric map key or ID
  • Expects an error or panic for an out-of-range code point
  • Cannot name strconv.Itoa as the decimal conversion