The loop that keeps your tool alive
You are building a database client. A user types SELECT * FROM users and expects rows to appear. They type INSERT INTO logs... and expect confirmation. The program does not exit after one command. It waits for the next one. This pattern is a REPL: Read-Eval-Print Loop.
A REPL is the backbone of interactive tools. Debug shells, calculators, configuration editors, and CLI utilities all rely on the same structure. The program reads a line of text, evaluates the command, prints the result, and repeats. In Go, the standard library provides everything you need to build a robust REPL. The challenge is handling input correctly so the user never sees garbled text or a frozen prompt.
How a REPL works in Go
A REPL is a loop that coordinates three phases. The read phase captures user input from standard input. The eval phase parses the text and runs the corresponding logic. The print phase writes output to standard output. The loop continues until the user signals exit or an error occurs.
Go's os.Stdin is a file handle connected to the terminal. Reading from it directly works, but it is inefficient for line-based input. The bufio package wraps os.Stdin in a buffer, allowing you to read text line-by-line without managing raw bytes. bufio.NewReader creates a buffer with a default size of 4096 bytes. This buffer sits between the OS and your code, reducing system calls and making input handling faster.
The core method is ReadString('\n'). This call blocks until the user presses Enter. It returns the text including the newline character, or an error if the input stream closes. The newline character must be stripped before processing, otherwise your command parser will see trailing whitespace. strings.TrimSpace removes the newline and any surrounding spaces.
Minimal REPL skeleton
Here's the simplest REPL: a buffered reader, an infinite loop, and a prompt.
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// main starts the REPL loop.
func main() {
// bufio wraps Stdin to read line-by-line instead of byte-by-byte.
reader := bufio.NewReader(os.Stdin)
for {
// Print the prompt so the user knows it's their turn.
fmt.Print("> ")
// ReadString stops at the newline character and includes it in the result.
input, err := reader.ReadString('\n')
if err != nil {
// Handle EOF or read errors gracefully.
fmt.Println(err)
break
}
// Trim the newline and whitespace for clean processing.
cmd := strings.TrimSpace(input)
// Echo back the command to demonstrate the loop.
fmt.Println("You typed:", cmd)
}
}
The loop runs forever until break or return. ReadString blocks until the user hits Enter. If the user sends an EOF signal (Ctrl+D on Linux/Mac, Ctrl+Z on Windows), ReadString returns io.EOF. The error check catches this and exits the loop. strings.TrimSpace ensures the command is clean. The echo proves the loop is working.
A REPL is just a loop. Don't overcomplicate the skeleton.
Realistic command dispatch
A real REPL needs to parse commands and route them to handlers. The input is a string of tokens separated by whitespace. strings.Fields splits the string by any whitespace and returns a slice of tokens. This handles multiple spaces between words and ignores leading/trailing whitespace automatically.
Here's a REPL that parses commands and dispatches to handlers.
// runREPL drives the interactive session.
func runREPL(reader *bufio.Reader) {
for {
fmt.Print("calc> ")
// ReadString blocks until Enter is pressed.
input, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "input error: %v\n", err)
return
}
// Fields splits by whitespace and ignores empty lines.
parts := strings.Fields(strings.TrimSpace(input))
if len(parts) == 0 {
continue
}
// Dispatch based on the first token.
switch parts[0] {
case "quit":
return
case "add":
handleAdd(parts[1:])
default:
fmt.Println("unknown command:", parts[0])
}
}
}
The switch statement routes commands. quit returns from the function, ending the REPL. add passes the remaining arguments to a handler. The default case prints an error for unrecognized commands. This structure scales: add more cases to the switch as you add features.
Here's a handler that validates arguments and performs the action.
// handleAdd parses two numbers and prints the sum.
func handleAdd(args []string) {
if len(args) != 2 {
fmt.Println("usage: add <a> <b>")
return
}
var a, b int
// Sscanf parses formatted text into variables.
_, err := fmt.Sscanf(args[0], "%d", &a)
if err != nil {
fmt.Println("invalid number:", args[0])
return
}
_, err = fmt.Sscanf(args[1], "%d", &b)
if err != nil {
fmt.Println("invalid number:", args[1])
return
}
fmt.Println(a + b)
}
The handler checks argument count first. fmt.Sscanf parses integers from strings. If parsing fails, the handler prints an error and returns. This keeps the REPL alive even when the user makes a mistake. The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Errors are handled immediately, not deferred.
Parse early, fail fast. If the input is garbage, tell the user immediately.
Handling background work
Some REPL commands start long-running tasks. A start-server command might launch a goroutine that listens for connections. The REPL must continue accepting commands while the server runs. If the user types quit, the server should stop.
Go's context package manages cancellation. The REPL creates a context and passes it to background tasks. When the user quits, the REPL cancels the context. Background tasks detect cancellation and shut down cleanly.
Here's how to run a background task that respects cancellation.
// runBackgroundTask demonstrates a long-running operation.
func runBackgroundTask(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
// Done returns a channel that closes when ctx is cancelled.
case <-ctx.Done():
fmt.Println("task cancelled")
return
case <-ticker.C:
fmt.Println("tick")
}
}
}
The select statement waits for two events. ctx.Done() closes when the context is cancelled. ticker.C fires every second. If cancellation happens first, the task returns. The defer ticker.Stop() ensures the ticker is cleaned up.
Convention dictates that context.Context is always the first parameter, named ctx. Functions that accept a context must respect cancellation and deadlines. This convention allows callers to control the lifetime of operations.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and errors
REPLs have subtle pitfalls. The most common is buffering. fmt.Print writes to os.Stdout, which is buffered. On some systems, the prompt might not appear until a newline is written. Users see a frozen terminal. The fix is to flush the output after printing the prompt. fmt.Fprint(os.Stdout, "> ") writes directly to the file, but flushing still requires os.Stdout.Sync(). Alternatively, use fmt.Println for the prompt if a trailing newline is acceptable, though this moves the cursor to the next line.
Another pitfall is ignoring errors. If ReadString returns an error and you ignore it, the loop might continue with empty input. The compiler won't stop you, but the runtime will fail. If you access parts[0] without checking the slice length, the program panics with runtime error: index out of range [0] with length 0. Always check slice bounds before indexing.
Input encoding can also cause issues. ReadString returns UTF-8 text by default. If the terminal sends raw bytes or different encoding, parsing may fail. For most tools, UTF-8 is sufficient. If you need to handle passwords or raw terminal modes, use golang.org/x/term. This package provides functions to read passwords without echoing and to switch the terminal to raw mode.
Flush the prompt. Users hate waiting for the cursor to appear.
When to use what
Building a REPL involves trade-offs. The standard library is sufficient for simple tools. Third-party libraries add features but increase dependencies. Choose based on your requirements.
Use bufio.NewReader with ReadString('\n') when you need a simple line-based REPL with minimal dependencies. Use golang.org/x/term when you need to read passwords without echoing or handle raw terminal modes. Use a third-party library like go-prompt when you need autocomplete, syntax highlighting, or history navigation. Use a single goroutine for the REPL loop when the REPL is the only thing running; spawning extra goroutines for input adds complexity without benefit.
Start with stdlib. Add libraries only when the user experience demands it.