DEV Community

Cover image for The YAML Norway problem and cron's day-of-month trap: two config formats that lie to you
DevOps Daily
DevOps Daily

Posted on

The YAML Norway problem and cron's day-of-month trap: two config formats that lie to you

Feed four innocent lines to PyYAML, Python's everyday YAML library:

python3 -c 'import yaml; print(yaml.safe_load("country: NO\nversion: 1.10\nmode: 0755\non: push"))'
Enter fullscreen mode Exit fullscreen mode
{'country': False, 'version': 1.1, 'mode': 493, True: 'push'}
Enter fullscreen mode Exit fullscreen mode

Norway's country code is now the boolean False. Version 1.10 is the float 1.1, a lower number than 1.9. The file mode is the decimal 493, and the on: key every GitHub Actions workflow needs has become True. That is the YAML Norway problem, and it has a cousin in cron: an expression that reads like "the first Monday of the month" and runs seven days in a row.

Neither format is broken. Both follow rules that are written down; the rules are not the ones you assume. Two free browser simulators make those rules visible: the YAML Parsing Simulator and the Cron Expression Simulator. Disclosure: I help build them; both are free, run in the browser, no signup.

Why NO becomes false

YAML has two versions in wide use, and they disagree about plain, unquoted values. YAML 1.1 reads y, yes, on, n, no and off (in several capitalisations) as booleans and treats a leading zero as octal. YAML 1.2's core schema accepts only true and false as booleans and reads 0755 as the decimal 755. Which rules you get depends on your parser and its settings, not on your file. PyYAML follows most of the 1.1 rules (it leaves single-letter y and n as strings). js-yaml 5.4.2 with its default schema uses the 1.2 rules, and on the same document it returns "NO" as a string and 755 as the mode.

See both specs at once

The YAML Parsing Simulator has an editor on the left and the parse result on the right, under YAML 1.2, with a second pane showing "The same file, older parser" under YAML 1.1. A "Types resolved" panel lists every value that stopped being a string, and when the two specs disagree, a table appears with the path, what you wrote, the 1.1 result and the 1.2 result. The parser is a small reader built for this job, not a full YAML implementation, and it runs as you type. It resolves values but not keys, so the on: surprise from the opening only shows up in a real parser.

It ships with ten lessons. The ones worth doing first:

  • The Norway problem. A country list with code: NO and code: SE. Under 1.1, only Norway's code turns into false, so the bug shows up in one row and in some parsers.
  • Versions are not strings. version: 1.10 is a float in both specs, so the trailing zero is gone before anything compares versions.
  • File modes and leading zeros. defaultMode: 0755 is 493 under 1.1 and 755 under 1.2. Same file, two numbers, and only one is valid: Kubernetes takes defaultMode as octal up to 0777 or decimal up to 511.
  • Quoting is the fix. code: "NO", version: "1.10", mode: "0755": an untagged quoted scalar is a string in both specs. The exception is a field that must be a number, such as defaultMode: write the decimal 493, which both specs read the same way.
  • A duplicate key is silent. replicas: 3 at the top of the file and replicas: 1 further down. The last one wins. PyYAML returns {'replicas': 1, 'image': 'myapp:v2', 'resources': {'cpu': '500m'}} without a warning; js-yaml 5.4.2, with default settings, refuses the document with "duplicated mapping key". Same file, a different failure mode per tool.

The others cover empty values against ~, null and "", block scalars (> folds line breaks into spaces, which breaks a shell script), anchors and merge keys, tabs in indentation, and a colon with no space after it.

Edit the lessons. Add enabled: yes to any of them and watch the disagreement table grow.

Cron's OR rule

Now cron. You want a report at 09:00 on the first Monday of each month, so you write:

0 9 1-7 * MON
Enter fullscreen mode Exit fullscreen mode

Days 1 to 7, Mondays only. Except that is not what it means. From man 5 crontab: "If both fields are restricted (ie, aren't *), the command will be run when either field matches the current time." Day-of-month and day-of-week are OR, not AND.

Paste that expression into the Cron Expression Simulator, set the start to 23 September 2026, choose 10 runs, and the list shows Monday 28 September, then every day from Thursday 1 October to Wednesday 7 October. The simulator states it in plain words ("on days 1, 2, 3, 4, 5, 6 and 1 more of the month and also on Monday") and adds a note: "Day-of-month and day-of-week are OR, not AND".

The standard fix is to restrict one field and test the other inside the job:

0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/monthly-report
Enter fullscreen mode Exit fullscreen mode

date +%u prints 1 for Monday. The backslash matters: in a crontab line, an unescaped % becomes a newline and the rest goes to the command's standard input.

Four more ways cron surprises you

The simulator's list of built-in examples is a tour of them:

  • The uneven step. */7 * * * * is not "every 7 minutes". It matches minutes 0, 7, 14 ... 56, then the hour restarts, so the last gap of every hour is 4 minutes. That is 216 runs a day. */5 has no such gap because 60 divides by 5.
  • The run that never happens. 30 1 * * * in Europe/London. On 29 March 2026 the clock goes from 00:59 to 02:00, so 01:30 does not exist that day and the simulator lists it as skipped.
  • The same job, firing twice. The same expression on 25 October 2026, when 01:30 happens twice. The run list marks it "twice".
  • The one that never fires. 0 0 30 2 * is valid syntax. February has no 30th. The simulator searches eight years ahead, finds nothing and tells you so.

A caution on the daylight saving examples: the simulator shows the plain wall-clock answer, and real schedulers differ. Debian's cron(8) says it runs jobs from a skipped hour soon after the change, and does not re-run jobs in a repeated hour if the clock moved back by less than 3 hours. Other schedulers make their own choices. The safe habit is the same everywhere: keep jobs out of the small hours in zones with daylight saving, or schedule in UTC. The simulator has 13 timezones and a button that jumps to the next clock change, so you can test that.

The shared lesson

Both formats resolve what you typed into something else, silently. The defences are the same too: make the meaning explicit (quote YAML strings, restrict one cron day field), and check what the tool actually does. For YAML, yamllint's truthy rule flags yes, off and friends in CI.

Both simulators are part of 50+ free DevOps games and simulators. For a longer read, see StrictYAML's write-up of the Norway problem. The YAML 1.2.2 spec and crontab(5) are the primary sources for everything above.

Top comments (0)