RouterOS scripts fail silently. That’s the single most important thing to know about them. A scheduler fires a script whose policy doesn’t permit what it does — nothing happens, nothing is logged by default. A :global is referenced without being declared — the variable is empty, the script carries on with an empty string where your WAN IP should be. Nobody notices until the failover script that “worked in testing” does nothing at 3 AM.
The scripting language itself is fine. It’s not Python and stops pretending to be about ten minutes in, but it has variables, arrays, loops, and since recent releases, real error handling. What it doesn’t have is guardrails. Every convention in this article exists because I, or someone whose postmortem I read, got bitten by its absence.
This is the language reference I wish I’d had: the constructs that matter, the patterns that survive contact with production, and the footguns ranked by how much sleep they’ve cost people.
Variables and the Scope Trap
Two declarations, one trap:
:local counter 0 # visible only in this script/block:global wanState "up" # persists across scripts, survives script exitThe trap: a global must be declared in every script that touches it. :global doesn’t just create a variable — it imports the name into the current script’s scope. Skip the declaration and you silently read an empty value:
# script A ran earlier::global wanState "down"
# script B, WRONG - $wanState is undefined here, prints nothing::put $wanState
# script B, RIGHT::global wanState:put $wanState # -> downThe second trap is block scope. Anything declared inside { } — including the body of an :if or :foreach — dies at the closing brace. Declare variables at the top of the script, assign inside blocks:
:local activeCount 0:foreach i in=[/interface find where running] do={ :set activeCount ($activeCount + 1)}:put $activeCount # works, declared outside the loopGlobals persist until reboot or explicit removal, and they’re visible in /system script environment — which doubles as your debugger:
/system script environment print# remove a stale global:/system script environment remove [find name=wanState]Arrays
Two forms — indexed and keyed:
:local ips {192.0.2.1; 192.0.2.2; 192.0.2.3}:put ($ips->0) # first element
:local peer {name="fra1"; asn=64512; up=true}:put ($peer->"name") # keyed accessIterate with :foreach, which hands you value — or key and value:
:foreach ip in=$ips do={ :put "checking $ip" }
:foreach k,v in=$peer do={ :put "$k = $v" }Handy conversion: :toarray splits a comma-separated string, which is how you turn a stored config value into something loopable:
:local watchHosts [:toarray "198.51.100.1,198.51.100.9"]find / get / set: The Pattern Behind Everything
Nearly every useful script is a variation of: find items matching a condition, read or change them.
find returns internal IDs of matching items:
[/interface find where name~"^ether"] # regex-ish match with ~[/ip firewall address-list find where list=blocklist]get reads properties — of one item:
:local wanIP [/ip address get [find interface=ether1] address]:put $wanIPset writes:
/interface set [find name=ether5] comment="uplink to sw-core-01"The composite pattern, which you’ll write a hundred times — loop over finds, get inside the loop:
# disable all wifi interfaces with no clients:foreach i in=[/interface/wifi find where !disabled] do={ :local regs [/interface/wifi/registration-table find where interface=[/interface/wifi get $i name]] :if ([:len $regs] = 0) do={ :log info ("no clients on " . [/interface/wifi get $i name]) }}Two rules keep this from blowing up. First, find returns an array — passing multiple IDs to get is an error, so either index it or loop over it. Second, always handle the empty result. [find ...] matching nothing returns an empty array, and get on that fails:
:local ids [/ip route find where comment="backup-default"]:if ([:len $ids] > 0) do={ /ip route set $ids disabled=no} else={ :log warning "backup-default route not found"}That :if [:len ...] guard is the difference between a script that degrades gracefully and one that dies mid-run, leaving your config half-changed.
Error Handling: :do on-error and :onerror
The classic construct — try a block, run a fallback if it throws:
:do { /tool fetch url="https://api.example.com/health" as-value output=user} on-error={ :log error "health check fetch failed"}Without this, any runtime error — DNS failure in :resolve, timeout in fetch, a get on a missing item — kills the whole script at that line. Wrap every operation that talks to the network or depends on config that might not exist.
Newer 7.x releases (7.17+) add :onerror, which also captures the error message — worth it for logs that say why:
:onerror e in={ :resolve api.example.com} do={ :log error "resolve failed: $e"}You can also throw your own with :error "message" — useful to abort early when preconditions fail — and :retry wraps flaky operations with attempts and delay:
:retry command={/ping 198.51.100.1 count=1} delay=2 max=5One discipline note: on-error is for handling errors, not muting them. An empty on-error={} is how scripts fail silently for months. At minimum, log.
Strings: The Painful Part
There is no split, no real regex in the string functions, no format strings. You get [:len], [:pick] (substring by positions), and [:find] (position of substring). Parsing anything means combining them manually:
# extract "192.0.2.10" from "192.0.2.10/24":local cidr "192.0.2.10/24":local slash [:find $cidr "/"]:local ip [:pick $cidr 0 $slash]:put $ipThat’s the canonical example, and every real case is worse. Time values, fetch output, interface stats — plan on writing little pick/find state machines and testing them against real data in the terminal before trusting them. Interpolation helps readability: "$var" expands inside double quotes, and $(expr) evaluates expressions inline:
:put "free memory: $([/system resource get free-memory] / 1048576) MiB"Mind the types. "5" and 5 are different things, and comparisons between them do not do what you hope. Convert explicitly with :tonum, :tostr, :toip at the boundary where data enters your script — especially anything read from comments, fetch results, or user input.
The one genuine gift in modern 7.x: :deserialize parses JSON into arrays, which turns “parse this API response with :pick” into a non-problem:
:local result [/tool fetch url="https://api.example.com/status" as-value output=user]:local data [:deserialize from=json ($result->"data")]:put ($data->"status")If your RouterOS version has it, use it and delete your hand-rolled parser.
Footguns, Ranked
1. Script policies. Every script has a policy list (read, write, test, policy, password, reboot, …), and so does whatever runs it. A scheduler with policy=read firing a script that writes config fails — silently. Netwatch is capped at read,write,test,reboot, so scripts needing more can’t run from netwatch at all. When a script works in the terminal but not from its trigger, check policy first; it’s the cause roughly 80% of the time.
/system script print detail # note the policy column/system scheduler print detail2. Global scope, again. Undeclared globals read as empty. Combine with silent failure and string/number confusion and you get scripts that “run fine” and do nothing.
3. :delay in event scripts. A :delay 30s inside a DHCP lease script or netwatch hook blocks that subsystem’s script execution. If you need to wait, hand the work to a scheduler entry or spawn it with :execute instead of sleeping inline.
4. Half-completed runs. Scripts aren’t transactions. If line 12 of 20 throws, lines 1–11 already happened. Structure scripts so the dangerous part is one operation at the end, validate everything first, and wrap the lot in :do on-error with a log line.
5. Editing scripts in WinBox with smart quotes. Paste from a word processor and " becomes " and nothing parses. Keep scripts in Git as plain text — I covered the config-as-code argument in the VyOS automation article, and it applies doubly to a language this fragile.
A Template That Holds Up
Every production script I deploy follows this skeleton:
# script: check-wan-failover# policy needed: read, write, test
:global failoverActive:if ([:typeof $failoverActive] = "nothing") do={ :set failoverActive false }
:onerror e in={ :local pingResult [/ping 198.51.100.1 count=3] :if ($pingResult = 0 && !$failoverActive) do={ /ip route set [find comment="backup-default"] disabled=no :set failoverActive true :log warning "WAN failover: backup route enabled" } :if ($pingResult > 0 && $failoverActive) do={ /ip route set [find comment="backup-default"] disabled=yes :set failoverActive false :log warning "WAN failover: primary restored" }} do={ :log error "check-wan-failover failed: $e"}Declared globals with initialization, guarded finds keyed on stable comments rather than item numbers (item numbers are not stable — never script against them), one clear state change, and logging on both action and failure. Boring by design.
Closing Thoughts
RouterOS scripting rewards paranoia. Declare your globals, guard your finds, convert your types, wrap your network calls, log your failures, and check your policies before you check anything else. None of it is hard; all of it is unforgiving if skipped, because the platform’s default failure mode is silence.
Keep scripts small and single-purpose. The moment one grows past a screen, it’s a sign the logic belongs off-box — in Ansible or the REST API, which close out this series. On-box scripts shine as reflexes: failover checks, lease hooks, alert triggers. The next article wires those reflexes to their triggers — the scheduler, netwatch, and event scripts — and covers how to keep scheduled automation from stepping on itself.