Choosing a Language for MDM Script Distribution — Tradeoffs Across Bash and Go

Tadashi Shigeoka ·  Tue, April 7, 2026

Any organization that manages corporate Macs or Windows endpoints with an MDM (Mobile Device Management) eventually runs into the same operational shape: ship a script to every device and run it. Configuration drift remediation, security posture checks, internal tool installation, login item hygiene. Whether you are on Jamf Pro, Kandji (now Iru), Mosyle, Microsoft Intune, or JumpCloud, the last mile is “execute something in a shell.”

What is less obvious is which language to write that something in. Bash gets you a pre-installed runtime but is hard to keep idempotent at scale. Swift or Go produce a fast single binary, but you take on code signing and notarization.

This post lays out a practical decision frame for MDM script distribution, comparing Bash / zsh, Swift, Go, and PowerShell on pre-installed runtimes, code signing, distribution format, logging, and idempotency, and lands on a practical split for macOS and Windows.

Constraints Specific to MDM Distribution

Before language comparison, four constraints apply to anything you ship through an MDM. A language that fails any of them rarely makes the cut, no matter how productive it feels.

1. You can’t quietly add a runtime

The script runs on a managed device. Optional runtimes (Node.js and similar) need to be present already, or the script needs to no-op when they are missing, or it needs to fetch them. Languages with a pre-installed runtime are inherently easier to distribute because they remove that whole layer.

2. Scripts usually run as root

Jamf Pro scripts and Intune macOS shell scripts execute as root by default. You don’t need sudo, but writing into a user’s home directory ends up root-owned and breaks ownership later. Knowing whose UID you’re acting as is part of the contract.

3. Exit codes are how dashboards count failures

MDM reports aggregate success and failure on process exit codes. A language that lets you emit exit codes deliberately (set -e in Bash, exit(1) in Swift / Go) is what keeps your dashboard truthful. The moment a try/except quietly swallows an error and returns 0, your fleet of failing devices stops showing up.

4. Binary distributions need code signing and notarization

Anything that runs as a Mach-O binary on macOS goes through Gatekeeper and notarization. Plain text scripts are exempt, but a Swift or Go binary that you ship without signing will get blocked the moment it lands on a user’s Mac.

Language-by-Language Comparison

A summary table against the constraints above:

LanguagePre-installed on macOSPre-installed on WindowsDistribution formCode signing / notarization
Bash (/bin/bash 3.2)YesNo (WSL aside)Single .shNot required
zsh (/bin/zsh)Yes (default user shell on macOS)NoSingle .shNot required
Swift (binary)Compiler no, runtime bundled in macOSNoBinary or PKGRequired
Go (binary)NoNoBinary or PKGRequired on macOS
PowerShellNoYes (5.1 ships in box; 7+ separately)Single .ps1Sometimes required

The rest of this section drills into where each language earns its place.

Bash / zsh: The De Facto Standard for Mac MDM

Bash and zsh are the de facto standard for MDM-distributed scripts. Vendor templates from Jamf, Kandji, Mosyle, and Intune, and almost every script published in the MacAdmins community, are shell. The reason is straightforward: no extra runtime, and you can ship them as raw .sh to MDM or wrap them in a pkg interchangeably.

Two macOS-specific quirks matter. First, /bin/bash on macOS is pinned at 3.2 for GPLv3 reasons, so mapfile, associative arrays, and coproc are off-limits in stock environments. Second, the default user shell on macOS is zsh, but a script run as root works fine with #!/bin/zsh or #!/bin/bash. Choose by team convention.

The hidden strength of shell is reproducing failures. When something breaks on a fleet device, bash -x ./script.sh over SSH gives you trace output immediately. With a binary, the equivalent debug loop requires a local rebuild, which slows incident response on production fleets.

The weakness shows up at scale. A 500-line shell script is hard to test and easy to make inconsistent. To survive long term, you need set -euo pipefail boilerplate, shellcheck in CI, and logging through logger into Unified Logging.

Swift: Strongest Choice When macOS-Only Is Acceptable

Swift is hard to beat when the target is exclusively macOS. Three reasons:

  1. The Swift runtime ships with macOS, so the deliverable is a single binary.
  2. Apple maintains first-party CLI libraries: Swift Argument Parser, the System framework, and others.
  3. SystemConfiguration, Security, and CoreFoundation are directly accessible, which is far more reliable than parsing system_profiler or defaults output from shell.

For “read this slightly tricky piece of macOS state and report it back” tasks (Wi-Fi SSID, FileVault status, profile contents), a small Swift binary breaks less often than a Bash + system_profiler pipeline.

The cost is the build pipeline. The binary needs an Apple Developer ID signature and notarization. If the script touches anything covered by TCC (Transparency, Consent, and Control) (Full Disk Access, Screen Recording, etc.), you also need a PPPC payload pushed through MDM, otherwise root execution gets denied at the access-control layer rather than at the process level.

Swift wins when “macOS-only, but I need APIs that shell can’t reach” is the constraint. Outside of that, the distribution overhead rarely pays off versus shell.

Go: Strongest Choice for Cross-Platform

Go is the first pick when you need the same tool on macOS and Windows. GOOS=darwin GOARCH=arm64 and GOOS=windows GOARCH=amd64 cross-compile cleanly, and the output is a single runtime-free binary. This is why endpoint-side fleet tooling like osquery, Munki’s Managed Software Center adjacent tools, and Fleet’s agent are commonly Go.

On macOS the same signing and notarization requirements apply, and supporting both Apple Silicon and Intel means universal binary builds.

The downside is verbosity for small recipes. A 30-line Bash script becomes a 200-line Go program. Go pays off when you actually need cross-platform parity, binary format read/write, or concurrent calls to many APIs. Use it where its strengths are real, not as a default.

PowerShell: The Standard for Windows, Limited for Mac

PowerShell is the de facto standard on the Windows side. The Intune Management Extension (PowerShell scripts) and JumpCloud command runs targeting Windows are PowerShell by default. Windows 10 and 11 ship with Windows PowerShell 5.1, so .ps1 files run with no extra runtime.

PowerShell 7 is cross-platform and installable on macOS, but unifying macOS scripts under PowerShell rarely justifies the install. The industry default settles at “PowerShell on Windows, zsh / Bash on macOS.”

One Windows-specific gotcha: execution policy. MDM-pushed scripts typically launch with -ExecutionPolicy Bypass. If you choose to ship signed scripts, build certificate rotation into the MDM workflow from day one.

Decision Matrix by Workload

Mapping the comparison onto common script categories:

flowchart TD
    Start[Script to distribute] --> Q1{Target OS}
    Q1 -- Windows --> PS[PowerShell 5.1 .ps1]
    Q1 -- macOS only --> Q2{Touches macOS APIs}
    Q1 -- Both --> Go[Go binary in PKG / MSI]
    Q2 -- Yes --> Q3{One-shot or daemon}
    Q2 -- No --> Bash[Bash / zsh .sh]
    Q3 -- One-shot --> Swift[Signed Swift binary]
    Q3 -- Daemon --> Daemon[launchd plus Swift / Go binary]

In typical use cases:

  • Drift remediation, small defaults write fixes, log collection: Bash / zsh.
  • Profile content validation, FileVault state checks, hardware-specific decisions that lean on macOS APIs: Swift.
  • Same internal tool needed on macOS and Windows: Go binary in a PKG / MSI.
  • Active Directory / Windows domain integration: PowerShell.

The right answer is rarely “all Bash” or “all Go binaries.” Mac-only shops gravitate toward Bash plus Swift; mixed Mac and Windows shops shift weight toward Go.

Choosing the Distribution Format

Once the language is picked, the distribution format is next. MDMs offer roughly three entry points.

FormatFitsStrengthsCaveats
MDM script feature (raw text)Bash / zsh / PowerShellFast iteration, change history in the MDM UIBinaries don’t fit; some MDMs cap at ~500 KB
PKG (macOS) / MSI (Windows)Swift / Go binariesBundle binaries, support files, and launchd plistsNeeds a signing / notarization (or signed-MSI) pipeline
Custom-app feature in the MDMAnythingForce placement on diskAPI differs per MDM, accumulating tech debt during migration

Pasting a .sh is the fastest path. As volume grows you’ll want stricter version control and reproducible builds, at which point even shell scripts benefit from being wrapped into a PKG.

Logging and Idempotency: Non-Optional Plumbing

Independent of language choice, three operational requirements apply to every MDM script.

1. Send logs to Unified Logging

On macOS, use logger or os_log to write into Unified Logging instead of appending to /var/log/install.log. Filtering by subsystem and category, plus log show --predicate, makes fleet-wide investigation tractable. On Windows, the equivalent is the Windows Event Log.

2. Write idempotent code by default

MDM scripts re-run. They re-run because policy changes, because a device returns from sleep, because a check-in cycle ticks. Plan for it: an early-exit guard like if [ -f /Library/Foo/installed.flag ]; then exit 0; fi, reading current values before defaults write, an os.Stat check in Go before doing the work.

3. Add a dry-run flag from the start

A --dry-run flag means CI and production can use the same script. Swift Argument Parser and PowerShell [switch] parameters express this declaratively, which is one place where a more structured language pays off versus shell.

Conclusion

Picking a language for MDM script distribution is not really a language comparison. It is three questions: where does the runtime come from, how much signing-and-notarization overhead is acceptable, and how cheap do failure reproductions need to be? Three takeaways:

  • For small to medium scripts on macOS-only fleets, Bash / zsh remains the strongest pick. Combine set -euo pipefail and shellcheck and reach for a Swift binary when shell can’t get the data you need.
  • For mixed macOS and Windows fleets, Go is the first language to consider. If your CI absorbs the binary signing pipeline, long-term maintenance is cleaner than juggling two shell dialects.
  • When you need to touch macOS APIs, carve a small Swift binary out instead of stretching shell. It breaks less than parsing defaults or system_profiler output, and the operational logs become easier to reproduce.

Knowing when to step beyond Bash matters more than the absolute choice. Having Swift and Go ready in your back pocket lets you respond fast when the fleet outgrows what shell can express, and that, in practice, is the productive position to be in.

That’s all from someone choosing a language for MDM script distribution. From the gemba.

References