1. Why use flag when you have os.Args
When you write a CLI program, you almost always want the user to be able to configure its behavior: enable debug mode, set a limit, choose an output format. You can, of course, parse os.Args by hand, but that quickly starts to feel like unpacking a suitcase after a vacation: “where did three extra bags come from and why are the socks in the fridge?”. The flag package is the standard way to parse flags in Go: it provides a unified syntax and removes part of the routine.
The fundamental idea is: os.Args is just a raw []string “as the OS passed it”, while flag can turn it into clear values of the required types. As a result, you get neat string/int/bool, rather than an endless parsing party: “was it -limit=10 or -limit 10, and what if it was -limit ten?”.
Internally, flag still looks at the launch arguments (by default, that is os.Args[1:]), but it does so centrally, the same way for all flags, and according to standard rules.
You can visualize it like this:
flowchart LR
A["os.Args (raw []string)"] --> B["flag.Parse()"]
B --> C["flag values: string/int/bool"]
B --> D["positional arguments: flag.Args()"]
2. Declaring flags
When you first see the flag API, it can seem strange: why does flag.String declare a flag rather than read it? Why does it return a pointer? And why can’t you just write “read my flags” with a single command? These are all features of the flag model: first we describe which flags the program supports, then we call parsing, and only then do we read the results.
flag.String, flag.Int, flag.Bool
Flags are declared with functions from this family:
- flag.String(name, defaultValue, usage)
- flag.Int(name, defaultValue, usage)
- flag.Bool(name, defaultValue, usage)
Each of them returns a pointer to the value (*string, *int, *bool). We’ll unpack why a bit later, but for now remember the practical rule: after Parse(), read the value via *.
A small table to make it easier on the eyes:
| Function | Value type | Returns |
|---|---|---|
|
|
|
|
|
|
|
|
|
Mini example: let’s declare three flags for our learning “pseudo-todo” (for now it doesn’t store anything—we just print what we would do).
package main
import (
"flag"
"fmt"
)
func main() {
title := flag.String("title", "", "task title")
priority := flag.Int("priority", 1, "priority 1..5")
done := flag.Bool("done", false, "mark task as done")
flag.Parse()
fmt.Println(*title, *priority, *done)
// (output example depends on the launch arguments)
}
Notice: we do not touch os.Args by hand at all. That’s the first “win” over chaos.
Why it returns *T
When a beginner sees *title, they often think: “I asked you to just read a string—why did you give me… the address of a string?”. That’s a normal question. The answer is that flag first creates a “container” for the value, and then during Parse() it writes the parsing result into it.
You can imagine it as a label on your desk:
- title is a card that says where the real value is stored
- *title is the value itself stored at that address
So flag.String(...) returns not the string, but “a place where the string will be put later”. And after flag.Parse(), that place is filled with either what the user entered or the default value.
Mini demo where we print types to calm the brain down:
package main
import (
"flag"
"fmt"
)
func main() {
title := flag.String("title", "untitled", "task title")
fmt.Printf("%T\n", title) // *string
flag.Parse()
fmt.Printf("%T\n", *title) // string
}
We’re not going deep into pointers as a topic here (that will be a separate big story), but it’s important to get used to one habit: always read a flag value via *.
3. Parsing arguments: flag.Parse()
In flag there is an almost ritual order of actions: “declare → parse → read”. If you break this order, the program will be about as useful as an alarm clock without batteries: it looks familiar, but doesn’t help much.
flag.Parse() does several things at once. It takes the launch arguments, recognizes known flags among them, converts values to the required types (for example, the string "10" into int(10) for flag.Int), and stores the result into those pointer variables you received when declaring flags. Everything that is not a flag is left as “the remainder”—positional arguments.
A simple example of what happens if you forget Parse(). (This is a common mistake, so it’s useful to see it once.)
package main
import (
"flag"
"fmt"
)
func main() {
limit := flag.Int("limit", 10, "max items")
fmt.Println(*limit) // 10 (always 10 until you call Parse)
}
And here is the correct version:
package main
import (
"flag"
"fmt"
)
func main() {
limit := flag.Int("limit", 10, "max items")
flag.Parse()
fmt.Println(*limit) // depends on -limit ...
}
An important nuance: flag supports both popular syntaxes for passing values: -limit=5 and -limit 5. You don’t need to write two parsers and guess which variant the user chose.
4. Positional arguments
Flags are named settings (“enable this”, “set the limit to that”). But many commands also have positional arguments: a file, a path, an entity name, task text. They come without a name and are determined by order.
After flag.Parse() you can get positional arguments in three ways:
- flag.Args() returns a []string with all positional arguments
- flag.NArg() returns the number of positional arguments
- flag.Arg(i) returns the i-th positional argument (zero-based)
Let’s make our “todo command” a bit more alive: let the task title be a positional argument, and the priority be a flag. That means the launch looks roughly like this:
- todo -priority 3 "Buy milk"
Code:
package main
import (
"flag"
"fmt"
)
func main() {
priority := flag.Int("priority", 1, "priority 1..5")
flag.Parse()
fmt.Println("args:", flag.Args()) // args: [...]
fmt.Println("priority:", *priority) // priority: ...
}
Check flag.NArg() before flag.Arg(0)
Now let’s add reading the “title” as the first positional argument, but carefully: first we check that it actually exists.
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() < 1 {
fmt.Println("missing TITLE")
return
}
fmt.Println("title:", flag.Arg(0))
}
A very important habit here: don’t read flag.Arg(0) blindly. If there are no arguments, you’ll get an empty string, and the program may “seem to work” but do meaningless things. That’s worse than an explicit error, because bugs like this live long and feed on your confidence.
5. Useful CLI nuances
Boolean flags: flag.Bool
Boolean flags are a special kind of CLI joy. Unlike strings and numbers, they are often used as a “mode switch”: enable/disable something. And a good CLI usually lets you write them in a short form.
In flag.Bool there is a practical UX detail: you can set a flag as -done=true, but usually you can also just write -done, which means true. That makes commands shorter and nicer.
Let our “todo” have a -done flag that marks a task as completed (yes, it’s weird to create a task already completed—but a CLI, like life, doesn’t have to be logical).
package main
import (
"flag"
"fmt"
)
func main() {
done := flag.Bool("done", false, "mark task as done")
flag.Parse()
fmt.Println("done:", *done) // done: true/false
}
If the user didn’t pass -done, the value stays at the default (false). If they did pass it, it becomes true. And importantly, you don’t have to manually parse the string "true"/"false" and think about what to do with "yes".
Terminator --: “everything after this is not flags”
There is one small CLI problem that can surface unexpectedly. Imagine a positional argument itself starts with -. For example, you want to pass task text -buy milk (maybe that’s a style) or a file named -data.txt (yes, that happens).
Without an extra rule, flag will try to interpret this as a flag. To explicitly say “that’s it, no more flags; after this, just arguments”, you use the -- terminator.
From the user’s perspective, the command looks like this:
- todo -priority 2 -- -buy milk
The code doesn’t change: you still call flag.Parse(), then read flag.Args(). The -- just helps the parser split the parts correctly.
Mini example that shows the fact that “after -- everything goes into Args”:
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
fmt.Println(flag.Args()) // everything after -- will be here
}
Mini example: building a “launch config”
Now we’ll assemble a small complete piece: flags + a positional argument, and print what exactly the program understood. This is not a “real todo application” yet, but it is already a correct “launch interface” layer.
The rules are simple: priority and done are flags, TITLE is a positional argument.
package main
import (
"flag"
"fmt"
)
func main() {
priority := flag.Int("priority", 1, "priority 1..5")
done := flag.Bool("done", false, "mark as done")
flag.Parse()
title := ""
if flag.NArg() >= 1 {
title = flag.Arg(0)
}
fmt.Println("title:", title, "priority:", *priority, "done:", *done)
}
Yes, we’re not validating the priority range and we’re not printing usage nicely yet—those are separate skills. At this stage, what matters is getting the mechanics into muscle memory: declare → Parse → read → look at Args.
6. Common mistakes
Error #1: you forgot to call flag.Parse() and are surprised that “flags don’t work”.
This is the most common story. The program starts, nothing crashes, but any -priority 5 seems to be ignored. The reason is simple: until you call Parse(), your variables contain only default values.
Error #2: you try to use title instead of *title and get weird compile errors.
flag.String returns a *string, not a string. This is done so that Parse() can write the result into a pre-prepared place. The fix is simple: after Parse(), always dereference *title, *limit, *done.
Error #3: you declare flags after flag.Parse() and expect them to be recognized.
Parse() parses arguments based on the list of already-declared flags. If you declare a flag later, Parse() won’t “know” about it, because parsing has already happened. The rule is very strict: declarations first, then Parse().
Error #4: you read positional arguments before Parse() and get “empty/not what you expected”.
Before Parse(), the flag package hasn’t separated flags from positional arguments yet. So flag.Args() before parsing will either be empty, or contain unexpected things. The correct order: flag.Parse() and only then flag.Args()/flag.NArg()/flag.Arg(i).
Error #5: you read flag.Arg(0) without checking flag.NArg() and end up with “silent” wrong logic.
Unlike reading os.Args[i], here you usually won’t get a panic—but you will get an empty string, and then the program may create a task with an empty title and pretend that’s intended. Before reading positional arguments, it’s useful to check the count, even if you really want to “save two lines”.
GO TO FULL VERSION