Read User Input From the Terminal in Go Line by Line
Read lines from standard input with bufio.Scanner, handle end of file and the 64 KB line limit, parse numbers safely and test the code without a keyboard.

A program that talks to a person, or to a pipe, needs to read lines. In Go there are four ways to do it and one of them is the right default. The others, fmt.Scanln, bufio.Reader.ReadString and reading everything with io.ReadAll, each have a place, and each has a trap that I will point out as we reach it. This how-to builds a small interactive program from the scanner up, shows what happens at the limits (end of file, a huge line, bad numbers), and finishes with the testable form. It belongs in the Go basics path, builds on functions, and sits next to command line arguments and files.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The scanner loop
bufio.Scanner is the default for line-oriented input. It wraps any io.Reader, splits it into lines and hands them to you one at a time:
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
total, lines := 0, 0
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
lines++
n, err := strconv.Atoi(text)
if err != nil {
fmt.Printf("line %d: %q is not a number\n", lines, text)
continue
}
total += n
}
if err := scanner.Err(); err != nil {
fmt.Println("read error:", err)
return
}
fmt.Println("lines:", lines, "total:", total)
}The loop condition does the work: Scan() returns true while there is another line and false when the input ends or an error occurs. Text() returns the line without its newline. After the loop, Err() tells you whether it ended normally. A clean end of input is not an error, and Err() returns nil for it, which is why the check goes after the loop and not inside it. Feed the program a few lines through a pipe, with a blank line and a bad value among them:
printf '10\n\n20\nabc\n30\n' | go run .line 3: "abc" is not a number
lines: 4 total: 60The blank line was skipped, abc was reported with its position, and the three good lines were summed. Because the program reads the same way from a pipe as from a keyboard, it works in both situations without any change, which is the property that makes Unix filters composable. If standard input is a terminal, Scan() blocks until the user presses Enter, and Ctrl-D on Unix or Ctrl-Z on Windows ends the input. Files created on Windows end their lines with a carriage return and a newline, and the default scanner strips both, so a CRLF file reads the same as an LF one. That is not true of ReadString, which keeps the carriage return, and an invisible carriage return at the end of a value is a classic reason a comparison that looks equal fails.
The limit that surprises people
The scanner reads each line into a buffer of 64 KB by default. A line longer than that stops the scan, and Err() returns bufio.Scanner: token too long. Normal terminal input never gets there. Data files and pasted JSON can. Here is the program from above fed 70,000 bytes in one line:
head -c 70000 /dev/zero | tr '\0' 'a' | go run .read error: bufio.Scanner: token too longThe output reports the read error, and nothing was summed. The fix is to give the scanner a bigger buffer before the first Scan, with a maximum:
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)That allows lines up to 10 MB. Choose the maximum deliberately, since it is the largest allocation an input line can force on your program. If lines can be unbounded, or you need to handle huge lines without holding them in memory, use bufio.Reader and read in chunks instead.
Other ways, and the traps
bufio.Reader.ReadString with a newline delimiter returns the line including the delimiter and an error, io.EOF at the end. It has no length limit, and you must handle a final line without a newline, which arrives together with io.EOF. It is the right tool when lines are huge or when you need to read a partial line. The scanner is simpler for everything else.
fmt.Scanln and fmt.Scan read space-separated tokens and are tempting for "read a number". They also stop at spaces, so a prompt that expects a name like Ada Lovelace reads only Ada, and they behave differently for Scan and Scanln around newlines. A leftover newline from one call is read by the next, which produces the "my second prompt gets skipped" bug. I use them in throwaway scripts and nowhere else.
io.ReadAll(os.Stdin) reads everything until the input ends. It is right for "read the whole document" and wrong for interactive programs, since it blocks until the end. It also allocates the whole input, so bound it with io.LimitReader for untrusted input.
Prompts and interaction
An interactive program prints a prompt, waits for a line and acts on it. Write the prompt to standard output without a newline using fmt.Print, and the program's own messages to standard error if they should not mix with piped output:
fmt.Print("name: ")
if !scanner.Scan() {
return
}
name := strings.TrimSpace(scanner.Text())For passwords and other secrets, the terminal must not echo what is typed. The standard library has no function for it. golang.org/x/term provides term.ReadPassword(int(os.Stdin.Fd())), which reads a line with echo disabled and returns the bytes. Use it and zero the slice when you are done. Never read a password with the scanner, since the characters appear on screen and in the scrollback.
Make it testable
A function that reads from os.Stdin directly is hard to test, because the test would need a terminal or a subprocess. Take an io.Reader instead, and the test passes a string:
package sum
import (
"bufio"
"io"
"strconv"
"strings"
)
// Total adds up the integers in r, one per line, and reports the lines it skipped.
func Total(r io.Reader) (total int, skipped []string, err error) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
n, convErr := strconv.Atoi(text)
if convErr != nil {
skipped = append(skipped, text)
continue
}
total += n
}
return total, skipped, scanner.Err()
}package sum
import (
"slices"
"strings"
"testing"
)
func TestTotal(t *testing.T) {
input := "10\n\n20\nabc\n 30 \n"
total, skipped, err := Total(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
if total != 60 {
t.Errorf("total = %d, want 60", total)
}
if !slices.Equal(skipped, []string{"abc"}) {
t.Errorf("skipped = %v, want [abc]", skipped)
}
}
func TestTotalEmpty(t *testing.T) {
total, skipped, err := Total(strings.NewReader(""))
if err != nil || total != 0 || len(skipped) != 0 {
t.Fatalf("got %d, %v, %v", total, skipped, err)
}
}=== RUN TestTotal
--- PASS: TestTotal (0.00s)
=== RUN TestTotalEmpty
--- PASS: TestTotalEmpty (0.00s)
PASS
ok example.test/lab 1.270sThe same Total serves main with os.Stdin and the tests with strings, so the interesting logic has coverage and the thin main does not need any. This is the usual shape for command line programs in Go, and it is the reason accepting interfaces is a useful habit. Notice the test includes a line with spaces around a number and an empty input. Those two cases are where hand-rolled input code usually breaks.
When the terminal is the wrong interface
If your program needs structured input, flags and a file path are better than prompts, and command line arguments are the next stop. If it needs a rich interface with cursor control and menus, a terminal UI library is the answer, since the scanner has no idea about cursors or keys. And if the input is untrusted, whether piped or typed, bound it: cap line length, cap the number of lines and reject input that does not parse instead of guessing. The bufio package documentation describes the scanner's limits and custom split functions, and the golang.org/x/term package covers password reading.


