1. CLI arguments: what really gets into a Go program
When people hear “command-line arguments”, they often imagine a single string like todo add buy milk. But in Go you get already split pieces — a slice of strings []string. That’s convenient, but it’s important to understand the rules: the first element is the program name, and everything after that is what the user typed (in an already “tokenized” form).
Let’s poke at this hands-on — we’ll write a mini-program that simply prints the input.
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("os.Args = %#v\n", os.Args)
fmt.Println("program:", os.Args[0]) // program: ./app (example)
}
If you run it like this: ./app hello world, then os.Args will be roughly []string{"./app", "hello", "world"}. That is, Go no longer sees “the whole command”; it sees a list of “tokens”.
This leads to the first important conclusion: when you parse os.Args, you are in fact designing a mini input language that lives inside your command. And that mini-language will either be neat and predictable, or it will be a “surprise” for everyone — including you a week later.
2. The first success: one argument — and it feels like everything is simple
The first 10 minutes are usually inspiring. Need to accept one required argument? Easy: check the length — read os.Args[1]. Example: our “task manager” starter accepts a task title.
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("missing TITLE") // missing TITLE
return
}
title := os.Args[1]
fmt.Println("title:", title) // title: Buy milk
}
And indeed: “it works!”. Until the next requirement shows up: “let’s add priority”. Then “let’s mark completed”. Then “let’s print help”. Then “let’s allow a name that starts with -”. And that’s where os.Args starts hinting that it’s not breaking — we are breaking, because we’re trying to manually implement something that was solved a long time ago.
3. As soon as a number appears, manual parsing starts to bloat
As soon as you add a numeric argument, two things show up in the code: argument count checks and parsing error handling. That’s normal, but notice how quickly the “glue code” grows.
Suppose we want: todo TITLE PRIORITY, where PRIORITY is a number.
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("Usage: todo TITLE PRIORITY") // Usage: todo TITLE PRIORITY
return
}
title := os.Args[1]
priority, err := strconv.Atoi(os.Args[2])
if err != nil {
fmt.Println("PRIORITY must be int:", err) // PRIORITY must be int: ...
return
}
fmt.Println("task:", title, "priority:", priority) // task: Buy milk priority: 3
}
So far it’s tolerable. But it’s important to notice: we’re already starting to write “our own protocol”: what goes where, how many arguments, what format, what error messages. And that’s only two parameters. As soon as there are more, your main will start to look like an exam in “branching and suffering”.
4. When options appear, manual parsing falls apart
Positional arguments (the ones that go simply in order) are good when there are few of them and the order is natural. For example: cat FILE, go test ./..., git clone URL. But as soon as you add optional settings, positional arguments turn into a game of “guess what the user meant”.
Let’s imagine a contract for our program:
- required: TITLE
- optional: priority (default 1)
- optional: done (default false)
Intuitively, the user wants to type something like:
- todo -title "Buy milk" -priority 3
- todo -title "Buy milk" -done
- todo -title "Buy milk"
If we try to implement this manually on top of os.Args, we quickly get stuck in a swamp: we need to walk through the arguments, understand “is this a key or a value”, check that a key is followed by a value, and so on. Here is an example of “manual flag parsing” in the spirit of “I just tried it, and I already don’t like it”.
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
title := ""
priority := 1
done := false
for i := 1; i < len(os.Args); i++ {
switch os.Args[i] {
case "-title":
if i+1 >= len(os.Args) {
fmt.Println("missing value for -title") // missing value for -title
return
}
title = os.Args[i+1]
i++
case "-priority":
if i+1 >= len(os.Args) {
fmt.Println("missing value for -priority") // missing value for -priority
return
}
p, err := strconv.Atoi(os.Args[i+1])
if err != nil {
fmt.Println("bad -priority:", err) // bad -priority: ...
return
}
priority = p
i++
case "-done":
done = true
default:
fmt.Println("unknown argument:", os.Args[i]) // unknown argument: ...
return
}
}
fmt.Println("title:", title, "priority:", priority, "done:", done)
}
Yes, this works… until you add:
- short forms (-p 3),
- the -priority=3 format,
- -- as “everything after this is positional only”,
- repeatable flags,
- human-readable help,
- proper error messages and a single unified usage.
You literally start writing your own mini analogue of flag, only worse — because you’re doing it in the evening with a tired brain, without tests, and without any desire to maintain “yet another wheel reinvented”.
5. Typical pitfalls of manual parsing
There are a few places where manual parsing on top of os.Args is especially unpleasant. It’s important to feel them in advance so you don’t later wonder why “it seemed so simple”.
Imagine the user wants to add a task with a name that starts with a dash. For example, a task “-reboot router” (yes, weird, but real life has everything). In a manual parser, you usually interpret everything that starts with - as a flag. And a conflict appears: is it a title or a parameter?
Then comes the story of the “-- marker” (used by many CLIs), but if you didn’t account for it in your design, you either break the UX or invent hacks.
Another source of pain is different syntaxes. The user can type -priority 3, or -priority=3. If your parser supports only one, people will ask, “why isn’t it like normal programs?”. If it supports both, the code balloons.
And one more thing that beginners often underestimate: you almost always want “semantic” validation. The parser will turn a string into an int, but it doesn’t know that the priority must be 1..5. In the manual version, you start smearing rules across the code: some checks during parsing, some after, some somewhere “along the way”.
That’s exactly how you end up with a program where you can’t safely add a new parameter: it feels like any change can break something in an unexpected branch. Not because Go is weak, but because the manual input protocol started living its own life.
6. Maintenance: help, tests, and boundaries of responsibility
Help and usage easily drift out of sync
The next stage of pain is user guidance. At first you write something like:
fmt.Println("Usage: todo -title <text> [-priority N] [-done]")
Then you add -format, -limit, -verbose, and… forget to update usage. Or you update it in one place, but you also have two more “return with error” paths where usage is printed too — but in a different format.
As a result, the user gets three different hints depending on exactly how they made a mistake. And you get the feeling that “it kind of sprawled apart”.
This is an important CLI design principle: help must be generated from a single source of truth. If help lives separately from the parameter definitions, it will always lag behind. That’s why good flag libraries (and the standard flag) do it so the flag description is tied to its declaration, and help is built automatically.
When parsing mixes with logic, testing becomes painful
There is a very practical reason why manual parsing on top of os.Args starts to “break” quickly: it almost always ends up right inside main, next to the program logic.
At first it looks logical: “well, where else?”. But then you want to test the business logic. And how do you test it if it’s in main and reads os.Args directly?
You end up with an unpleasant coupling:
- to verify the logic, you need to run the program as a process,
- to check different argument variants, you need to build/patch os.Args,
- to check error messages, you need to capture console output.
Yes, it’s possible, but beginners usually find it uncomfortable, and most often no tests appear at all. And without tests, your manual parser quickly turns into a minefield.
When you use a standard flag parser, you typically move configuration into a struct, do “parse → validate → execute”, and the code becomes easier to test. Not because “it’s trendy”, but because you get a clear boundary between “input” and “logic”.
A mini-refactor on os.Args: it’s obvious we’re writing our own flag
To really “feel” the motivation, let’s do a small refactor of our tutorial application. We won’t hook up flag yet (that’s a separate topic), but we’ll do what people often do in real code as a first step: extract configuration and a parsing function. This is useful even if you still read os.Args manually, because it becomes easier on the brain.
First, define the configuration. Notice: here we store values, not “pointers to something”. We want the rest of the program to know nothing about os.Args at all.
package main
type Config struct {
Title string
Priority int
Done bool
}
Now we’ll make a parser. It’s still manual, but it already returns (Config, error) — the way we’re used to from lectures about errors.
package main
import (
"fmt"
"os"
)
func parseArgs(args []string) (Config, error) {
if len(args) < 2 {
return Config{}, fmt.Errorf("missing TITLE")
}
return Config{Title: args[1], Priority: 1, Done: false}, nil
}
And main becomes thinner:
package main
import (
"fmt"
"os"
)
func main() {
cfg, err := parseArgs(os.Args)
if err != nil {
fmt.Println("error:", err) // error: missing TITLE
return
}
fmt.Println("accepted:", cfg.Title) // accepted: Buy milk
}
It seems like it got better. But now imagine you add -priority and -done, and parseArgs grows again. You gradually come to the conclusion: “we’re building a universal argument parser”. Which means… yes. Exactly. You start building flag, only in a reduced, incompatible, and untested form.
And that’s the moment when you need to honestly say: manual parsing of os.Args is good as a learning stage and as a solution for “1–2 parameters with no variations”. But if the CLI is even slightly real-world — it’s time to use the standard mechanism.
7. What a proper parser should do and why it’s flag
If you gather the requirements for a “proper” CLI parser, it becomes clear why it shouldn’t be homegrown, especially at the beginning.
A proper solution must support a clear syntax with flags (named parameters) and positional arguments (required things in order). It must handle errors consistently and print help so that it matches the real parameters. It must provide defaults and let you validate the meaning separately from type parsing. And, ideally, it should be standard — so other people don’t have to learn your unique “argument dialect” like a new programming language.
In Go, the standard package for this is flag. Its key value isn’t that it’s “magical”; it’s that it solves the boring part: it parses arguments neatly and predictably, freeing you up for application logic.
8. Typical mistakes in manual os.Args parsing
Mistake #1: reading os.Args[1] without checking the length.
This is the most common cause of panics for beginners. In Go, slices don’t forgive “maybe there’s an element there”. If there are no arguments, indexing os.Args[1] will cause a runtime panic. Even if you’re sure the “user will always pass it”, practice quickly teaches that users are creative.
Mistake #2: mixing argument parsing and business logic execution in one place.
While the program is small, it feels faster. But as soon as you have a second validation rule, a third error message, and a fourth parameter, the code turns into mush. Life is much calmer when you have “first parse, then validate, then execute”.
Mistake #3: treating positional arguments as a universal solution.
Positional arguments are good while there are few of them. When there are many, the user mixes up the order, and you add checks like “len exactly N”, then “N or N+1”, then “if the third argument looks like a number…”. This is the road to an unpredictable interface.
Mistake #4: writing usage manually in multiple places.
Today you print Usage: ... in one place; tomorrow you add another error branch and forget to put it there too. As a result, the interface becomes inconsistent. If help is part of UX, then help drifting away from the real parameters is perceived as a bug.
Mistake #5: “silently swallowing” unknown arguments.
Sometimes beginners build a parser that ignores anything it doesn’t understand. It seems like “fewer errors”. In reality, it makes things worse: the user mistyped -priorty, and the program silently ran with the default. That’s not an error — it’s a trap.
GO TO FULL VERSION