Why does exec.Command("ls", "*.go") in Go pass the glob to ls literally instead of expanding it?
answer
- who expands a glob?
- no interpreter sits in between
- one string in, one argument out
- Cmd.Path plus Cmd.Args go to exec
- shell features need an explicit shell
basics
~20 sexec.Command starts the program directly through the operating system, with no shell involved. Every string after the program name becomes one exact argument, so ls receives the four characters *.go as text. Globbing, pipes, redirection and variable expansion are shell features.
solid answer
~40 s`exec.Command(name, arg...)` builds a `Cmd` whose `Path` is the executable that `exec.LookPath` resolved for `name`, and whose `Args` slice is `{name, arg...}`. Those arguments are handed to the OS as an argument vector, one element per string — nothing splits them on spaces, nothing re-parses them, and no shell ever sees them. So `*.go` arrives at `ls` as literal text, `$HOME` arrives as literal text, and a filename containing spaces needs no quoting at all because it is already a single element. The flip side is that shell syntax simply does not work: to get a pipeline or a glob you must run a shell as the program, for example `exec.Command("sh", "-c", script)`, and then you own the quoting of anything you interpolate into `script`.
code
go · 5 linescmd := exec.Command("ls", "*.go")
// cmd.Path holds the path LookPath resolved for "ls".
// cmd.Args is []string{"ls", "*.go"} - one element per string.
fmt.Println(len(cmd.Args)) // prints: 2
fmt.Println(cmd.Args[1]) // prints: *.gogo deeper
Recall that exec.Command takes the program name first and each argument as its own separate string, and that no shell is involved. Be ready to rewrite a one-string command line into the correct call.
Explain the mechanics: LookPath resolves the name into Cmd.Path, Cmd.Args becomes the argument vector, and the operating system's execute call receives it verbatim. Say precisely which shell features disappear as a result.
Show judgment about when running a shell explicitly is worth its portability and quoting costs, and how you would log what was actually executed when a step misbehaves on one host only.
Own the convention across a codebase: whether external commands are built in one place with a typed argument list, and what your policy is on anything that reaches for a shell string.
## What `exec.Command` actually builds `exec.Command("ls", "*.go")` does not run anything. It returns a `*exec.Cmd` — a struct describing a process you have not started yet. Two of its fields matter here: - **`Cmd.Path`** — the file that will be executed. If the name you passed contains no path separator, `exec.Command` calls `exec.LookPath` to search the calling process's `PATH` and stores the resolved path here. If the lookup fails, the error is remembered in `Cmd.Err` and surfaces later, when you call `Run`, `Start`, `Output` or `CombinedOutput`. - **`Cmd.Args`** — the argument vector, set to `{name, arg...}`. By long-standing Unix convention `Args[0]` is the program's own name, so the name you passed appears twice in the struct: once resolved in `Path`, once unresolved as `Args[0]`. When you finally start the process, the runtime performs the operating system's execute call with that vector. There is no intermediate interpreter. ## The argument-vector model A process on Unix receives its arguments as an array of strings. The kernel does not tokenise anything; whatever the parent handed over is exactly what the child sees. Word splitting, glob expansion (`*.go`), variable expansion (`$HOME`, `~`), pipes (`|`), redirection (`>`), command substitution and quote removal are all things a **shell** does *before* it performs that same execute call. Go's `os/exec` skips the shell, so it skips all of them. Consequences you can predict from that one fact: - `exec.Command("ls", "*.go")` runs `ls` with one argument, the two-character-plus-suffix string `*.go`. If no file is literally named `*.go`, `ls` reports that it cannot find it. - `exec.Command("echo", "$HOME")` prints the five characters `$HOME`. - `exec.Command("ls -l")` looks for an executable file whose **name** is the eight characters `ls -l`. `exec.LookPath` will not find it, and `Run` returns an `*exec.Error` wrapping `exec.ErrNotFound`. Writing the whole command line as one string is the single most common beginner mistake with this package. - Conversely, `exec.Command("rm", "my report.txt")` is correct as written. The space is inside one argument; adding quotes would make the filename contain quote characters. ## When you genuinely need a shell If the thing you want is a pipeline, a redirection or a glob, run a shell as the program and pass the script as one argument: `exec.Command("sh", "-c", "ls *.go | wc -l")`. That is a deliberate choice with two costs. First, portability: `sh` is not present in the same form on every platform, and Windows has no equivalent invocation. Second, you have re-entered a world where the text is parsed, so anything you interpolate into that script must be quoted by you. The wider question of what that means for input you do not control is a security topic in its own right; mechanically, the point here is that Go hands you a shell only when you explicitly ask for one. ## Windows Windows has no argument vector at the OS level — a process receives a single command-line string and parses it itself. `os/exec` therefore joins `Args` into a command line using the conventional quoting rules, so the Go-side model stays the same: you supply discrete strings and the package does the escaping. Programs that parse their command line unconventionally can still be surprised, which is why the package exposes a way to supply a raw command line, but that is an escape hatch, not the normal path. ## Reading the struct in a debugger or a log Because `Path` and `Args` are ordinary exported fields, they are the fastest way to answer "what exactly did we run?". `Cmd.String()` renders the resolved path plus the arguments in a human-readable form, which is useful in logs — but note it is for humans: it is not a string you can paste into a shell and expect identical behaviour, precisely because no shell was involved in the first place.
- What happens if you call exec.Command("git commit -m hi") with the whole line as one string?`exec.LookPath` searches `PATH` for an executable file literally named `git commit -m hi`, finds nothing, and the error is stored in `Cmd.Err`. You do not see it until you call `Run`, `Start`, `Output` or `CombinedOutput`, which returns an `*exec.Error` that matches `exec.ErrNotFound`. The fix is `exec.Command("git", "commit", "-m", "hi")`.
- Does an argument containing spaces or quotes need escaping before you pass it to exec.Command?No. Each argument is delivered to the child as its own element, so `"my report.txt"` arrives intact and adding shell quotes would make the quote characters part of the filename. Escaping only becomes your job if you deliberately run a shell and build a script string for it.
- What is the difference between Cmd.Path and Cmd.Args[0]?`Path` is the file that gets executed — usually the absolute path `exec.LookPath` resolved. `Args[0]` is the conventional program name the child sees as its own zeroth argument, which `exec.Command` sets to the unresolved name you passed. Some programs change behaviour based on `Args[0]`, and you can set it yourself by building the `Cmd` struct directly.
saying these in an interview costs you the question
- Thinks Go hands the command to /bin/sh by default
- Writes the whole command line as one string
- Quotes arguments that contain spaces
- Expects $VAR or ~ to expand inside an argument
- Believes pipes and redirection work without a shell