Shell scripting with Elixir

Feb 11, 2024

When simple bash scripts start to become unwieldy, you may consider reaching out for something “higher” level, like Perl, Ruby or Python. I’m reaching out for Elixir. Maybe the startup times are not perfect for every use case, but Elixir is extremely versatile. It’s easy to add dependencies, debug, iterate and even write tests, all in a single file! I believe, due to LiveBook, it really hits its stride in recent years; the ecosystem leans heavily into ergonomic developer experience and great out-of-the-box defaults (Req is amazing!). In few lines of code, you can connect to Postgres, send HTTP requests or start HTTP server.

For my own use cases I’ve written scripts for displaying weather(focus only on rain, v. important for dog walks when your dog hates rain) in i3status-rust, transforming various CSV files from bank exports into beancount format, or creating Telegram bots.

A simple script may be just:

#!/usr/bin/env elixir
IO.puts("Hello world")

Well, if it were that simple, I’d just do echo "Hello world" and skip Elixir, but then there’d be no point in writing this blog post, right? So, I have another, a bit more involved and what may seem like over-complicated template. It’s a starting point for more complex scripts, and I can either remove some of the parts I don’t need or start extending it.

The template

#!/usr/bin/env -S ERL_FLAGS=+B elixir

if System.get_env("DEPS_ONLY") == "true" do

defmodule Hello do
  @moduledoc """
  <!-- TODO -->

  ## Usage
      $ bin/hello --help

  @args [help: :boolean]
  def main(args) do
    {parsed, args} = OptionParser.parse!(args, strict: @args)
    cmd(parsed, args)

  defp cmd([help: true], _), do: IO.puts(@moduledoc)
  defp cmd(_parsed, _args) do


First of all, the shebang is not the usual thing you’d expect. It configures an additional flag for erl. This flag, per docs, “De-activates the break handler for (SIGINT) ^C and ^\ “. I’ll expand on it in Signals section.

Now, we’re executing, and there’s a ready-to-go Mix.install/2 statement where we can add additional dependencies. To make HTTP requests, we can add battries-included HTTP client like {:req, "~> 0.4"}. Processing JSON? {:jason, "~> 1.4"}. We can search for packages with mix [package-name] or lookup the latest version mix [package-name]

$ mix req
Req is a batteries-included HTTP client for Elixir.

Config: {:req, "~> 0.4.8"}
Releases: 0.4.8, 0.4.7, 0.4.6, 0.4.5, 0.4.4, 0.4.3, 0.4.2, 0.4.1, ...

Licenses: Apache-2.0

Another unusual block is if System.get_env("DEPS_ONLY") do ... end. If the script doesn’t have any dependencies, I’d just delete it or let it be. It’s useful in cases when the script has dependencies. We can use this block to cache dependencies and compilation of the script, skipping the execution of the rest of the script. This is handy for CI setups or when building container images. For CI, I’d also define a directory where the dependencies should be cached. for GitLab CI, a job may looks like this:

  image: hexpm/elixir:1.16.1-erlang-26.2.2-debian-bookworm-20240130-slim
    - key: elixir-cache
        - .cache
    - DEPS_ONLY=true bin/hello
    - bin/hello

After that, there’s a module where we’ll be documenting what the script is about, defining options for parsing arguments and failing gracefully when invalid options are passed, ensuring proper error exit code. That’s also where we’d extend the script, before last cmd/2. CLI argument parsing is done with built-in OptionParser.

This structure may seem a bit verbose and may look like a lot of boilerplate, but again, if it were simple, we’d have just stayed with the bash in the first place. Here, this structure can easily grow with the script.

I used to define inline functions like print_help = fn -> ... end or process_args = fn (args) -> .. end but in the end, working within a module is cleaner, and no need to look if given function is anonymous function (.() call) or module’s function.

With the template in place, we’re ready to add some logic to it. Elixir can do quite a bit just with the standard library, but there are also some gotchas. Let’s go through some common needs.


IO will probably be the most often used module. It can be used to write stdout with functions like IO.write/1, IO.puts/1, or to stderr with their equivalent 2-argument calls like IO.puts(:stderr, "Error"). We can also read inputs with Any writing or reading can be also handled as a stream with

IO.puts(:stderr, "Invalid argument"), :line) # that's the default
|>, "\n"))


There’s no need to define color codes manually. With IO.ANSI, we can add text and background colors easily.

iex(1)> IO.ANSI.blue_background()
iex(2)> v <> "Example" <> IO.ANSI.reset()
iex(3)> v |> IO.puts()

Exit code

Another quick one, there’s System.stop(exit_code) to gently shutdown VM, what may not be obvious is that it’s async process. Make sure to call Process.sleep(:infinity) after it to block the execution. This ensures that all the applications are taken down gently. There’s alternative of System.halt/1 and it forces immediate shutdown of Erlang runtime system.


For one-off commands, System.cmd/3 is enough.

iex(1)> {output, 0} = System.cmd("git", ["rev-parse", "--show-toplevel"])
{"/home/arathunku/code/\n", 0}

With pattern matching we ensure immediate exit if there’s any other exit code than the successful one, and we can process the output.

It gets more tricky if you want to create another BEAM process while continuing with the rest of the execution. If something goes wrong or Erlang system crashes, OS process might get left behind. In these cases, instead of reinveting a wheel of managing OS processes, it’s good occasion to make a use of this Mix.install/2 at the beginning and add MuonTrap. It will ensure the processes are, as described in README, well-behaved.

Mix.install([{:muontrap, "~> 1.0"}])

defmodule Hello do
  defp cmd([], []) do
    _pid = spawn_link(fn ->
      MuonTrap.cmd("ping", ["-i", "5", "localhost"], into:, :line))

Signals, some are not like the others

It’s tricky and it will probably not behave as you’d expect based on your experience in other languages. Some signals are handled by default by Erlang system, for more details check nice documentation in PR. For scripting… usually signals don’t matter that much, at least in my case. If you spawn a GenServer, it’ll receive terminate/2 for cleanup, assuming gentle shutdown.

We can still skip BREAK menu(^C) and exit immediately if we start with ERL_FLAGS=+B elixir. This is why it’s in the template at the beginning. Some other signals can be cough by swapping default erl_signal_server, but not all of them*. In the example above we’ll do just that, handle what we’re interested it and defer rest to the default handler.

*At this moment, INT cannot be trapped, see this issue

defmodule Signals do
  @behaviour :gen_event
  def init(_), do: {:ok, nil}

  def handle_event(:sigusr1, state) do
    IO.puts("USR1, continue...")
    {:ok, state}

  def handle_event(:sigterm, _state) do
    IO.puts("Ok, ok, let me take a moment and exit...")
  def handle_event(signal, state), do: :erl_signal_handler.handle_event(signal, state)

  def handle_call(_, state), do: {:ok, :ok, state}

  def terminate(reason, _state) do
    IO.puts("Goodbye! #{inspect(reason)}")
with :ok <- :gen_event.swap_handler(
  :erl_signal_server, {:erl_signal_handler, []}, {__MODULE__, []}
) do
  IO.puts("I'll wait for signals!")
  err ->
    IO.warn("Something went wrong. err=#{inspect(err)}")


In Rust, we can write tests next to the code with #[cfg(test)], and these will run when cargo test is executed. Did you know you can do kind of a similar thing in Elixir scripts? There’s no magic here, we need to create a test module and trigger ExUnit

if System.get_env("MIX_ENV") == "test" do

  defmodule HelloTest do
    use ExUnit.Case, async: true
    import ExUnit.CaptureIO

    test "prints a message when no arguments are passed" do
      assert capture_io(fn -> Tests.main([]) end) == "Hello World\n"
    test "prints help for unknown arguments" do
      assert capture_io(fn -> Tests.main(["--help"]) end) =~ "Example of adding ExUnit"

Going beyond scripts

OptionParser is good. Maybe it doesn’t do all the stuff that something like Rust clap does but it’s absolutely getting the job done.

You can go beyond simple CLI scripts and build full TUI apps. Progress bars? Charts? Text editor? Here, Ratatouille comes to the rescue. Simple TUI counter example or more advanced ones - more advanced examples.

If you’d like to see even more examples with Mix.install/2, make sure to check mix_install_examples! There’re examples of HTTP servers, CSV parsing, web scraping, machine learning or full Phoenix LiveView file uploader(!!!), but at this point you may consider just using mix new ... and setting up a proper Mix project. After that, you can use burrito to ship a single Elixir CLI binary for end-users.

Is Elixir more complicated than Ruby od Python as a bash replacement? It depends, of course it depends. If you already know Python or Ruby well, you’ll probably prefer them but Elixir can absolutely be used too! It’s a bit on the slow side to start up, may not be a best choice to implement PS1, but if the startup speed doesn’t matter that much and you want go have very ergonomic language for scripting at your hand - it’s great.

Closing note

Writing this article was kind of a strange experience. You may think it’s a bit light on examples and details, and it is done on purpose. There’s strong focus on documentation within the community and I didn’t want to repeat what’s already out there in official docs and libraries. Just look at this beautiful System.cmd/3 documentation or IO.ANSI docs, and it’s all available in iex with h/1.

When writing the article, I’ve dumped all my scripts and tests into this repository. Thanks for reading!