DEV Community

Cover image for Porting from Perl to Go: Simplifying for Platform Engineering
Mark Gardner
Mark Gardner

Posted on • Originally published at phoenixtrap.com on

Porting from Perl to Go: Simplifying for Platform Engineering

Rewriting a script for the Homebrew package manager taught me how Go’s design choices align with platform-ready tools.

The problem with brew upgrade

By default, the brew upgrade command updates every formula (terminal utility or library). It also updates every cask (GUI application) it manages. All are upgraded to the latest version — major, minor, and patch. That’s convenient when you want the newest features, but disruptive when you only want quiet patch-level fixes.

Last week I solved this in Perl with brew-patch-upgrade.pl, a script that parsed brew upgrade’s JSON output, compared semantic versions, and upgraded only when the patch number changed. It worked, but it also reminded me how much Perl leans on implicit structures and runtime flexibility.

This week I ported the script to Go, the lingua franca of DevOps. The goal wasn’t feature parity — it was to see how Go’s design choices map onto platform engineering concerns.

Why port to Go?

  • Portfolio practice : I’m building a body of work that demonstrates platform engineering skills.
  • Operational focus : Go is widely used for tooling in infrastructure and cloud environments.
  • Learning by contrast : Rewriting a working Perl script in Go forces me to confront differences in error handling, type safety, and distribution.

The journey

Error handling philosophy

Perl gave me try/catch (experimental in the Perl v5.34.1 that ships with macOS, but since accepted into the language in v5.40). Go, famously, does not. Instead, every function returns an error explicitly.

use v5.34;
use warnings;
use experimental qw(try);
use Carp;
use autodie;



try {
  system brew, upgrade, $name;
  $result = upgraded;
}
catch ($e) {
  $result = failed;
  carp $e;
}
Enter fullscreen mode Exit fullscreen mode
package main

import (
  os/exec
  log
)



cmd := exec.Command(brew, upgrade, name)
if output, err := cmd.CombinedOutput(); err != nil {
  log.Printf(failed to upgrade %s: %v\n%s,
    name,
    err,
    output)
}
Enter fullscreen mode Exit fullscreen mode

The Go version is noisier, but it forces explicit decisions. That’s a feature in production tooling: no silent failures.

Dependency management

  • Perl : cpanfile + CPAN modules. Distribution means “install Perl (if it’s not already), install modules, run script.” Tools like carton and the cpan or cpanm commands help automate this. Additionally, one can use further tooling like fatpack and pp to build more self-contained packages. But those are neither common nor (except for cpan) distributed with Perl.
  • Go : go.mod + go build. Distribution is a single (platform-specific) binary.

For operational tools, that’s a massive simplification. No runtime interpreter, no dependency dance.

Type safety

Perl let me parse JSON into hashrefs and trust the keys exist. Go required a struct:

type Formula struct {
  Name string `json:"name"`
  CurrentVersion string `json:"current_version"`
  InstalledVersions []string `json:"installed_versions"`
}
Enter fullscreen mode Exit fullscreen mode

The compiler enforces assumptions that Perl left implicit. That friction is valuable — it surfaces errors early.

Binary distribution

This is where Go shines. Instead of telling colleagues “install Perl v5.34 and CPAN modules,” I can hand them a binary. No need to worry about scripting runtime environments — just grab the right file for your system.

Available on the release page. Download, run, done.

Semantic versioning logic

In Perl, I manually compared arrays of version numbers. In Go, I imported golang.org/x/mod/semver:

import (
  golang.org/x/mod/semver
)



if semver.MajorMinor(toSemver(formula.InstalledVersions[0])) !=
  semver.MajorMinor(toSemver(formula.CurrentVersion)) {
  log.Printf(%s is not a patch upgrade, formula.Name)
  results.skipped++
  continue
}
Enter fullscreen mode Exit fullscreen mode

Cleaner, more legible, and less error-prone. The library encodes the convention, so I don’t have to.

Deliberate simplification

I didn’t port every feature. Logging adapters, signal handlers, and edge-case diagnostics remained in Perl. The Go version focuses on the core logic: parse JSON, compare versions, run upgrades. That restraint was intentional — I wanted to learn Go’s idioms, not replicate every Perl flourish.

Platform engineering insights

Three lessons stood out:

  1. Binary distribution matters. Operational tools should be installable with a single copy step. Go makes that trivial.
  2. Semantic versioning is an operational practice. It’s not just a convention for library authors — it’s a contract that tooling can enforce.
  3. Go’s design aligns with platform needs. Explicit errors, type safety, and static binaries all reduce surprises in production.

Bringing it home

This isn’t a “Perl vs. Go” story. It’s a story about deliberate simplification, taking a working Perl script and recasting it in Go. The aim is to see how the language’s choices shape a solution to the same problem.

The result is homebrew-semver-guard v0.1.0, a small but sturdy tool. It’s not feature-finished, but it’s production-ready in the ways that matter.

Next up: I’m considering more Go tools, maybe even Kubernetes for services on my home server. This port was a practice, an artifact demonstrating platform engineering in action.


Links

Top comments (1)

Collapse
 
jonasbn profile image
Jonas Brømsø

Great read @mjgardner

Just out of curiosity, the:

Original Perl script: brew-patch-upgrade.pl

Was not intended to be a link to the Perl source?