How to Create Interactive Prompts in a Go CLI

Cli
Create interactive Go CLI prompts by reading user input with bufio.NewReader and os.Stdin.

The blinking cursor problem

You are building a command-line tool that configures a database connection. The program pauses. A prompt appears: Enter host: . The cursor blinks. The user types localhost and hits Enter. The program captures the text, validates it, and moves to the next question.

This interaction feels instant to the user, but under the hood, Go is managing a stream of bytes, buffering input, waiting for a delimiter, and handling potential errors. Interactive prompts require you to treat standard input as a controlled resource rather than a firehose.

Go's standard library provides the tools to build these prompts without external dependencies. The core pattern relies on bufio to wrap os.Stdin, giving you line-oriented reading methods that block until the user commits their input.

Streams, buffers, and the newline trigger

os.Stdin implements the io.Reader interface. It is a stream of bytes coming from the terminal. If you read from it directly, you get raw bytes. You don't get lines. You don't get the ability to read until a newline character efficiently.

bufio solves this. It wraps an io.Reader with a buffer. The buffer reduces the number of system calls by reading chunks of data into memory. bufio.Reader exposes methods like ReadString and ReadLine that parse the buffer for you.

The newline character \n is the standard delimiter. When the user presses Enter, the terminal sends a newline. Your code waits for that signal. Until the newline arrives, the read operation blocks. The program is paused, waiting for the user.

Minimal prompt example

Here's the simplest interactive prompt: ask for a name, read the line, strip the newline, and print a greeting.

package main

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

// promptName asks the user for their name and returns the trimmed input.
func promptName() string {
	// bufio.NewReader wraps os.Stdin to provide efficient line-based reading.
	reader := bufio.NewReader(os.Stdin)
	
	fmt.Print("Enter your name: ")
	
	// ReadString blocks until it finds the delimiter or hits an error.
	// It returns the string including the delimiter.
	input, err := reader.ReadString('\n')
	
	// The community accepts the boilerplate because it makes the unhappy path visible.
	// Check for io.EOF or read errors immediately.
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
		os.Exit(1)
	}
	
	// TrimSuffix removes the newline character that ReadString includes in the result.
	// TrimSpace is safer than TrimSuffix('\n') to handle Windows carriage returns.
	return strings.TrimSpace(input)
}

func main() {
	name := promptName()
	fmt.Printf("Hello, %s\n", name)
}

Walkthrough of the read cycle

The function promptName demonstrates the standard pattern.

bufio.NewReader(os.Stdin) creates a buffered reader. The default buffer size is 4096 bytes. This is large enough for typical terminal input. The buffer sits between your code and the operating system. When you call ReadString, the reader checks the buffer. If the buffer has data, it scans for the delimiter. If the buffer is empty, it triggers a system call to fill the buffer from os.Stdin.

ReadString('\n') reads bytes until it finds \n. It returns the string including the \n. This is a common trap. If you print the result directly, you get an extra blank line. strings.TrimSpace removes the newline and any surrounding whitespace. It also handles \r\n on Windows, making your CLI portable.

The error check is mandatory. ReadString returns an error if the read fails. The most common error is io.EOF, which happens if the input stream closes unexpectedly. The compiler rejects the program with assignment mismatch: 2 variables but reader.ReadString returns 3 values if you forget to capture the error. Always capture it.

The convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors behind _ in prompt functions. If the read fails, the program should fail fast or handle the case explicitly.

Validation loops in practice

Real prompts rarely accept any input. You need validation. A prompt for a port number should reject text. A prompt for a password should enforce length. The validation logic belongs in the prompt function, not in the business logic that uses the value.

Here's a prompt that loops until the user enters a valid integer.

package main

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

// promptPort asks for a port number and loops until a valid integer is entered.
func promptPort() int {
	// bufio.NewReader wraps os.Stdin to provide efficient line-based reading.
	reader := bufio.NewReader(os.Stdin)
	
	for {
		fmt.Print("Enter port number (1-65535): ")
		
		// ReadString blocks until the user presses Enter.
		input, err := reader.ReadString('\n')
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
			os.Exit(1)
		}
		
		// TrimSpace removes the newline and surrounding whitespace.
		input = strings.TrimSpace(input)
		
		// strconv.Atoi converts the string to an int and reports conversion errors.
		port, err := strconv.Atoi(input)
		if err != nil {
			fmt.Println("Invalid number. Please try again.")
			continue
		}
		
		// Validate the range.
		if port < 1 || port > 65535 {
			fmt.Println("Port must be between 1 and 65535.")
			continue
		}
		
		return port
	}
}

func main() {
	port := promptPort()
	fmt.Printf("Selected port: %d\n", port)
}

The loop structure is key. for { ... } runs indefinitely until a return or break is hit. The continue statement jumps back to the top of the loop, re-prompting the user. This keeps the validation logic isolated. The function signature promises an int. The caller doesn't need to worry about strings or errors.

The compiler complains with cannot use input (type string) as int value in return if you try to return the raw string. The type system forces you to convert and validate before returning.

Handling sensitive input

Passwords are different. You don't want the password echoing to the terminal. The standard library doesn't include a password prompt in bufio. You need golang.org/x/term.

This package provides ReadPassword, which disables terminal echo temporarily. It reads from a file descriptor. os.Stdin.Fd() gives you the file descriptor for standard input.

package main

import (
	"fmt"
	"os"
	
	"golang.org/x/term"
)

// promptPassword reads a password without echoing it to the terminal.
func promptPassword() string {
	fmt.Print("Enter password: ")
	
	// term.ReadPassword disables terminal echo so the password isn't visible on screen.
	// It reads until a newline or EOF.
	// fd 0 is the standard file descriptor for stdin.
	bytes, err := term.ReadPassword(int(os.Stdin.Fd()))
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err)
		os.Exit(1)
	}
	
	// Print a newline after the password to move the cursor to the next line.
	// The terminal doesn't print a newline after the hidden input.
	fmt.Println()
	
	// Convert the byte slice to a string.
	return string(bytes)
}

func main() {
	password := promptPassword()
	fmt.Printf("Password length: %d characters\n", len(password))
}

term.ReadPassword returns a byte slice, not a string. This is intentional. Byte slices are easier to zero out for security. If you convert to a string, the value might linger in memory. For a simple CLI, converting to a string is acceptable, but be aware of the trade-off.

The fmt.Println() after the read is crucial. The terminal cursor stays at the end of the hidden input. Without the newline, the next prompt appears on the same line, creating a messy UI.

Secrets stay secret when you disable the terminal echo.

Pitfalls and compiler errors

Interactive prompts have subtle failure modes.

Carriage returns on Windows. ReadString('\n') works on Unix. On Windows, lines end with \r\n. If you only trim \n, you leave the \r in the string. strings.TrimSpace handles both. Don't hardcode delimiters for trimming.

Ignoring errors. The original snippet in many tutorials uses _ to discard the error. name, _ := reader.ReadString('\n'). This is dangerous. If the input stream closes, ReadString returns an empty string and io.EOF. Your program continues with empty data. Use _ sparingly with errors. Only discard an error if you have a fallback that makes sense. For prompts, the error usually means the user closed the terminal or piped input incorrectly. Exit or handle it.

Buffering mismatches. If you mix fmt.Scan and bufio.Reader, you get bugs. fmt.Scan uses its own internal buffer. bufio uses its own. They fight over the stream. Pick one approach and stick to it. bufio is the robust choice. fmt is for quick scripts.

Compiler errors. If you forget to import bufio, the compiler rejects the program with undefined: bufio. If you pass the wrong type to ReadString, you get cannot use '\n' (untyped rune constant) as string value in argument. ReadString expects a string delimiter, not a rune. Use "\n" or '\n'? ReadString takes a string. ReadByte takes nothing and returns a byte. Check the signature.

Validation loops belong in the prompt, not the business logic.

Decision matrix

Choose the right tool for the prompt complexity.

Use bufio.NewReader when you need full control over line parsing, error handling, and validation loops. This is the standard approach for most CLI prompts.

Use fmt.Scanln when you are writing a quick script and don't care about robust error recovery or Windows line endings. It's convenient but fragile.

Use golang.org/x/term when you need to hide sensitive input like passwords or PINs. The standard library doesn't provide this functionality.

Use a third-party library like survey or promptui when you need complex UI elements like checkboxes, select menus, or autocomplete. Don't reinvent the wheel for rich interfaces.

Where to go next