JavaRush /Courses /Go SELF /Debugging strings: %q and controlling “dirty” input

Debugging strings: %q and controlling “dirty” input

Go SELF
Level 14, Lesson 4
Available

1. String diagnostics: %q, bytes, and runes

When we print a string with the usual fmt.Println(s), we see something that looks like text. But a computer does not live in a world of “looks like”; it lives in a world of exact bytes. User input may contain a newline, a tab, a carriage return "\r" (hello, Windows), multiple spaces, invisible spaces — and all of this makes strings different even though they look identical to the human eye.

Imagine you check a command like this:

if cmd == "add" { ... }

But in reality cmd is "add\r" or "add " or "add\t". With your eyes you see "add", but the program sees “a different word”. And it’s right — it just has a bad attitude but a good memory.

To stop guessing, we will diagnose the string rather than “look at it”.

Mini app for experiments

To keep all examples connected, we’ll evolve a mini app: the simplest CLI script that reads one line for a command and one line for a task text. No files and no complex architecture — just input, cleanup, and clear output. The goal right now is not “to build a perfect todo app”, but to learn how to catch dirty input so you don’t spend an evening hunting an invisible "\r".

First, let’s sketch a program skeleton that reads the entire command (including spaces) and prints it as-is:

package main

import (
	"bufio"
	"fmt"
	"os"
)

func main() {
	r := bufio.NewReader(os.Stdin)

	cmd, _ := r.ReadString('\n')
	fmt.Println("command:", cmd) // command: add   (but there may be a newline here)
}

Note: for now we intentionally do not handle the error and do not trim the string. We want to see what the “raw” input looks like, and then learn how to put it in order.

The main tool: printing via %q

Typically, a beginner prints a string with Println and trusts what they see. That’s like checking water quality by taste: sometimes it works, but overall it’s questionable. The %q format in fmt.Printf is our “X-ray”: it prints the string in quotes and shows escape sequences explicitly. That means instead of a real newline you will see "\n", instead of a tab — "\t", instead of a quote — "\"".

Compare the output:

package main

import "fmt"

func main() {
	s := "add\r\n"

	fmt.Println(s)        // add
	fmt.Printf("%q\n", s) // "add\r\n"
}

With Println you get the illusion that “everything is fine”. With %q it becomes clear: the string contains two control characters.

Mini table: what most often “hides” in strings

To avoid keeping the entire Unicode universe in your head, it’s enough to recognize a few common “troublemakers” by name:

What it is How it looks in %q Why it matters
Newline
\n
Often remains after reading a line
Carriage return
\r
Typical in Windows line endings
\r\n
Tab
\t
May appear instead of spaces
Quote
\"
Inside data, CSV/JSON, etc.
Backslash
\\
Paths, regular expressions, escaping

And yes: %q is useful not only for diagnostics, but also for logs. When you write “received value X”, you really want to see that it is exactly X, not X plus two spaces and one "\r" “as a bonus”.

Escaping: "\n" is not two letters

This is a common point of confusion: a beginner sees "\n" in code and thinks it is two characters — \ and n. In a double-quoted string literal, "\n" means one control character: “newline”. So the length of that string is 1 byte.

Let’s verify it (and also practice %q):

package main

import "fmt"

func main() {
	a := "\n"
	b := "\\n"

	fmt.Printf("%q len=%d\n", a, len(a)) // "\n" len=1
	fmt.Printf("%q len=%d\n", b, len(b)) // "\\n" len=2
}

The first string actually moves to the next line when printed as %s, while %q shows it in a “safe mode”. The second string contains exactly two characters: a backslash and the letter n.

This is critical when you compare strings, slice them, or compute their length. If you confuse a “control character” with “two letters”, everything after that turns surreal: the program does “the wrong thing”, but formally it does exactly what you asked.

By the way, for tests it can sometimes be convenient to use a raw string literal in backticks, where escaping does not work. But that’s more about convenient constant notation than a primary debugging tool.

If %q is not enough: view bytes via []byte

There are situations where %q shows “something weird”, but you don’t understand what exactly got into the input. Then we move to the next level: show the exact bytes. In Go that’s simple: []byte(s).

Let’s see the difference between a space and a tab:

package main

import "fmt"

func main() {
	a := "A B"
	b := "A\tB"

	fmt.Printf("%q bytes=%v\n", a, []byte(a)) // "A B" bytes=[65 32 66]
	fmt.Printf("%q bytes=%v\n", b, []byte(b)) // "A\tB" bytes=[65 9 66]
}

Here 32 is a space, and 9 is a tab. Visually, in some fonts/editors a tab may look “like a few spaces”, but its bytes are completely different.

For our mini app this is especially useful when a command “seems like add”, but is not equal to "add". Then you print the bytes and see, for example, that the end contains 13 (that is "\r").

An important detail: len(s) for a string is the number of bytes. Therefore, len([]byte(s)) equals len(s) and often helps confirm you did not “lose” data during conversions.

Localizing the problem: printing by runes via range

When a string contains non-ASCII characters (for example, Russian letters), bytes become harder to interpret “by eye”. UTF-8 comes into play: one letter can take 2 bytes, 3 bytes, or even 4. In such cases it’s convenient to iterate over the string with range: you get runes (rune), i.e., Unicode code points.

The key is that range over a string gives you two values: i — the byte index, and r — the rune.

Here is “character-by-character” diagnostics:

package main

import "fmt"

func main() {
	s := "add\r\n"
	for i, r := range s {
		fmt.Printf("i=%d r=%q\n", i, r)
	}
	// i=0 r='a'
	// i=1 r='d'
	// i=2 r='d'
	// i=3 r='\r'
	// i=4 r='\n'
}

You can immediately see where the problem lives and which exact character is in the way.

This can also be used for “strange spaces”. For example, if a user pasted text from a document, it may contain an “unusual space”. With range you will see that it is not ' ' (a regular space), but something else. We won’t dive into Unicode nuances today, but the technique “look at runes” is your universal flashlight.

“Dashboard”: what to print while debugging

Sometimes %q is enough. Sometimes you want to see the type (maybe you’re printing not a string but a []byte and wondering why it looks odd). Sometimes you want to see the numeric code of a character.

Below is a compact “dashboard” for debugging a single string that you can temporarily paste into code:

package main

import "fmt"

func main() {
	s := "Hi,\r\n"

	fmt.Printf("s=%q\n", s)        // s="Hi,\r\n"
	fmt.Printf("type=%T\n", s)     // type=string
	fmt.Printf("len=%d\n", len(s)) // len=5 (bytes)
	fmt.Printf("bytes=%v\n", []byte(s))
}

If you are debugging a specific rune, then %q will show it “readably”, and %d will show it as a number:

package main

import "fmt"

func main() {
	r := '\n'
	fmt.Printf("r=%q code=%d\n", r, r) // r='\n' code=10
}

This is useful when you “see a space” but suspect it is not a space. The number helps you compare facts rather than feelings.

3. Input normalization: trim with intent

Trim by format, not “just in case”

It’s very tempting to write strings.TrimSpace(s) everywhere and enjoy life. Sometimes that is indeed the right move: commands, logins, “yes/no” answers, and identifiers typically should not depend on surrounding whitespace. But sometimes spaces are meaningful (for example, a “task name” might begin with a space if the user really wants that — weird, but people are capable of many things).

So the rule is: trim input where the format allows it.

Let’s improve our mini CLI: read the command, trim it, and show diagnostics (in case the command still is not recognized):

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	r := bufio.NewReader(os.Stdin)

	cmd, err := r.ReadString('\n')
	if err != nil {
		fmt.Println("read error")
		return
	}

	cmd = strings.TrimSpace(cmd)
	fmt.Printf("cmd=%q bytes=%v\n", cmd, []byte(cmd))
}

Now even if the user sends "add\r\n", after TrimSpace the command becomes "add".

One nuance: TrimSpace removes whitespace characters at the edges, not inside the string. That is usually what you want for commands.

Flowchart: how to debug a string

flowchart TD
    A["The string looks normal, but the logic does not work"] --> B["Print with %q"]
    B --> C{Do we see \\n/\\r/\\t/spaces?}
    C -- yes --> D["Fix: TrimSpace/Replace etc."]
    C -- no --> E["Print []byte(s) with %v"]
    E --> F{Is it clear which bytes are extra?}
    F -- yes --> D
    F -- no --> G["Iterate with range: i, r and print r=%q"]
    G --> D

The idea is simple: first the “X-ray” (%q), then a “blood test” ([]byte), then an “MRI” (range over runes).

Case: why the "add" command is not recognized

Now let’s assemble a typical bug. Suppose we want to recognize commands "add" and "list". We’ll make a simple check and intentionally add debug output that triggers when the command is unknown.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	r := bufio.NewReader(os.Stdin)

	cmdRaw, _ := r.ReadString('\n')
	cmd := strings.TrimSpace(cmdRaw)

	if cmd == "add" {
		fmt.Println("ok: add")
		return
	}
	if cmd == "list" {
		fmt.Println("ok: list")
		return
	}

	fmt.Printf("unknown command: %q\n", cmd)      // escaping is visible
	fmt.Printf("raw was: %q\n", cmdRaw)           // will show \r\n, if present
	fmt.Printf("raw bytes: %v\n", []byte(cmdRaw)) // exact bytes
}

Why are both values important here: cmdRaw and cmd? Because cmd is already the “cleaned up” variant. It is needed for logic. And cmdRaw is needed for diagnostics: if you trim too aggressively or trimming did not work, you want to see the original picture.

And yes, it’s normal to have multiple variables in a program: “raw input” and “normalized input”. It makes the code easier to understand. If you name them s1 and s2, you’ll end up debugging the names.

“Dirty” input in a task name: store carefully

Suppose the "add" command adds a task. We will ask the user to enter the task text on a separate line. And the problem appears again: after reading a line we will have "\n", and sometimes "\r\n". If we store the task “as-is”, then when printing the list, tasks may look weird (blank lines, jumpy layout).

Let’s make a minimal prototype: read a task title, trim the edges, and store it in a slice.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	r := bufio.NewReader(os.Stdin)
	tasks := make([]string, 0)

	line, _ := r.ReadString('\n')
	title := strings.TrimSpace(line)

	tasks = append(tasks, title)
	fmt.Printf("saved: %q\n", tasks[0]) // saved: "..."
}

Yes, for now we read only one line. But the idea matters: we store a normalized value, not a raw one. Then the rest of the code is simpler. If later you print tasks in a table or compare strings, you won’t have to remember about "\r" everywhere.

If you want to keep “exactly as the user entered it” — you can store the raw value too. But then you consciously accept that the data may contain trailing junk. And trailing junk bites later.

4. Common mistakes

Mistake #1: debugging strings only with fmt.Println and trusting your eyes.
Println displays a string “as text”, but hides control characters. As a result, "add\r\n", "add\n", and "add" can look the same even though string comparison logic honestly treats them as different. In such situations, fmt.Printf("%q\n", s) saves you — it makes the invisible visible.

Mistake #2: “fixing everything” with TrimSpace without understanding the format.
TrimSpace is an excellent tool for commands, keys, and “tokens”, but it can be wrong for data where leading/trailing spaces are meaningful (rare, but possible). A good style is to store raw and normalized values separately and trim only where the format explicitly allows it.

Mistake #3: trying to get the “first character of a string” via s[0] in Unicode text.
Indexing a string yields a byte, not a “character”. In UTF-8, one character can take multiple bytes, so s[0] for Russian text is just the first byte of the encoding, not a letter. For “by character” diagnostics, use for i, r := range s and print r with %q.

Mistake #4: confusing "\n" with "\\n".
In double quotes, "\n" is a newline (one character), while "\\n" is two characters: a slash and the letter n. If you mix them up, you may debug the wrong problem: you will think you have “an extra newline”, while in reality you have the text "\n" as two visible characters.

Mistake #5: ignoring the string read error and working with a “half string”.
Read methods (including ReadString('\n')) return both a string and an error. Even if today you’re writing a learning prototype, it’s useful to build the habit: if err != nil, choose a clear path (for example, print a message and exit). Otherwise you might debug “dirty input” when in fact the data simply was not fully read.

1
Task
Go SELF, level 14, lesson 4
Locked
Invisible Characters Detector
Invisible Characters Detector
1
Task
Go SELF, level 14, lesson 4
Locked
Command filter
Command filter
1
Task
Go SELF, level 14, lesson 4
Locked
Control character hunt
Control character hunt
1
Task
Go SELF, level 14, lesson 4
Locked
First difference
First difference
1
Survey/quiz
Strings, bytes, runes, 14 level, 4 lesson
Unavailable
Strings, bytes, runes
Strings, bytes, runes
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION