JavaRush /Courses /Go SELF /Why os.Args “breaks” — motivation for flag

Why os.Args “breaks” — motivation for flag

Go SELF
Level 48, Lesson 0
Available

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.

1
Task
Go SELF, level 48, lesson 0
Locked
Run Inspector
Run Inspector
1
Task
Go SELF, level 48, lesson 0
Locked
Newcomer Greeting
Newcomer Greeting
1
Task
Go SELF, level 48, lesson 0
Locked
Task card
Task card
1
Task
Go SELF, level 48, lesson 0
Locked
Argument parser
Argument parser
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION