Elixir has been adored by engineers for quite the time now and that is reflected in Stack Overflow’s 2025 survey. Phoenix, the web framework for Elixir, also ranked high in the survey.
So why am I telling you this? Well, because I wanted to thoughts on the language as I’ve worked with it for over 2 years know and honestly it’s amazing.
I recently started yet another personal project and opted to write it in Go because I simply wanted to get better at Go and I do like the language but I’ve ended up missing a lot of the functionality Elixir has to offer but here I’ll explore just a few:
- Pattern Matching
- Piping
- The Mighty with clause
Elixir’s pattern matching, function heads, and piping make complex data transformations feel like a walk through the Jedi Temple - elegant, powerful, and flowing naturally. Let’s explore a real-world example of processing user data through multiple validation and transformation steps.
The flow we’ll implement in both languages
Let’s assume we need to process user registration data through several steps:
- Validate input format
- Check if user already exists
- Hash the password
- Create user profile
- Send welcome email
- Return success response
Elixir Implementation with pipe operator
defmodule UserRegistration do
# Pattern matching with different function heads
# Here we try to pattern match on the email, name and password fields
# If all three do not exist we will hit the process_registration function and return an error
def process_registration(%{email: email, password: password, name: name})
# is_binary essentially checks if the values are valid strings
# by using the when guard clause which is nice because it's very simple to validate the incoming values somewhat straight away
when is_binary(email) and is_binary(password) and is_binary(name) do
# Beautiful piping - each function feeds into the next
# The result of the previous function is passed as the 1st argument for the next function
# Each function returns {:ok, data} or {:error, reason}, so we need to handle this properly
%{email: email, password: password, name: name}
|> validate_input()
|> check_user_exists()
|> hash_password()
|> create_user_profile()
|> send_welcome_email()
|> build_success_response()
|> case do
{:ok, response} -> response
# Here the reason could be any error coming from the above function
# In the example using the with clause we can explicitly list them
# But here I have a catch all and in a real world scenario would probably just Log the Error
{:error, reason} -> {:error, reason}
end
end
def process_registration(_invalid_input) do
{:error, "Invalid input format"}
end
# Individual functions with pattern matching
defp validate_input(_), do: {:error, :invalid_input}
defp validate_input(%{email: email, password: password, name: name})
when byte_size(password) >= 8 and byte_size(name) > 0 do
case Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) do
true -> {:ok, %{email: email, password: password, name: name}}
false -> {:error, :invalid_email}
end
end
defp check_user_exists({:error, reason}), do: {:error, reason}
defp check_user_exists({:ok, %{email: email} = data}) do
case Database.user_exists?(email) do
false -> {:ok, data}
true -> {:error, :user_exists}
end
end
defp hash_password({:error, reason}), do: {:error, reason}
defp hash_password({:ok, %{password: password} = data}) do
hashed_password = Bcrypt.hash_pwd_salt(password)
{:ok, Map.put(data, :password, hashed_password)}
end
defp create_user_profile({:error, reason}), do: {:error, reason}
defp create_user_profile({:ok, %{email: email, password: password, name: name}}) do
user = %User{
email: email,
password: password,
name: name,
created_at: DateTime.utc_now()
}
case Database.insert_user(user) do
{:ok, saved_user} -> {:ok, saved_user}
{:error, _} -> {:error, :database_error}
end
end
defp send_welcome_email({:error, reason}), do: {:error, reason}
defp send_welcome_email({:ok, %User{email: email, name: name} = user}) do
case EmailService.send_welcome(email, name) do
{:ok, _} -> {:ok, user}
{:error, _} -> {:error, :email_failed}
end
end
defp build_success_response({:error, reason}), do: {:error, reason}
defp build_success_response({:ok, %User{id: id, email: email, name: name}}) do
{:ok, %{
id: id,
email: email,
name: name,
message: "User registered successfully"
}}
end
end
I mean come on, this reads out beautifully and it’s just a glimpse of how good pattern matching and piping can be! And whilst I don’t really mind the whole Go error checking
if err != nil {
...
}
Elixir essentially does the same as we have a check for every function call to see if the result is successful or not but it looks so much nicer.
Note If you are using Cursor/ Copilot then the AI auto complete for error handling in Go is indeed fantastic
Alright, let’s have a look at how the same code would look like in Elixir if we used the with clause:
Elixir Implementation with the with clause
defmodule UserRegistrationWith do
# With clause for complex conditional logic
def process_registration_with_conditions(user_data) do
with {:ok, validated} <- validate_input(user_data),
{:ok, _exists_check} <- check_user_exists(validated),
{:ok, hashed} <- hash_password(validated),
{:ok, profile} <- create_user_profile(hashed),
{:ok, _email} <- send_welcome_email(profile) do
build_success_response(profile)
else
{:error, :invalid_email} -> {:error, "Invalid email format"}
{:error, :user_exists} -> {:error, "User already exists"}
{:error, :weak_password} -> {:error, "Password too weak"}
{:error, :email_failed} -> {:error, "Failed to send welcome email"}
error -> {:error, "Unknown error: #{inspect(error)}"}
end
end
end
I mean, again, this is so easy on the eye. You could expand on the error handling if there was additional logic other than returning the error and have a separate function to handle the different type of errors but I won’t go into that now.
Alright, let’s have a look at how this might look when implemented in Go but really it applies to most languages since you do want to check for errors explicitly anyway.
Note I’m nowhere near and expert in Go so the code could probably be improved drastically but even then the point that Elixir is more elegant stands.
Go Implementation
package auth
import (
"errors"
"fmt"
"regexp"
"time"
)
type User struct {
ID int `json:"id"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type RegistrationRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
type RegistrationResponse struct {
ID int `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Message string `json:"message"`
}
type RegistrationResult struct {
User *User
Error error
}
func ProcessRegistrationWithResult(req RegistrationRequest) RegistrationResult {
// We need to handle each step manually with explicit error checking
validated, err := validateInput(req)
if err != nil {
return RegistrationResult{Error: err}
}
exists, err := checkUserExists(validated)
if err != nil {
return RegistrationResult{Error: err}
}
if exists {
return RegistrationResult{Error: errors.New("user already exists")}
}
hashed, err := hashPassword(validated)
if err != nil {
return RegistrationResult{Error: err}
}
user, err := createUserProfile(hashed)
if err != nil {
return RegistrationResult{Error: err}
}
err = sendWelcomeEmail(user)
if err != nil {
return RegistrationResult{Error: err}
}
return RegistrationResult{User: user}
}
func validateInput(req RegistrationRequest) (RegistrationRequest, error) {
if req.Email == "" || req.Password == "" || req.Name == "" {
return RegistrationRequest{}, errors.New("all fields are required")
}
if len(req.Password) < 8 {
return RegistrationRequest{}, errors.New("password too short")
}
emailRegex := regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
if !emailRegex.MatchString(req.Email) {
return RegistrationRequest{}, errors.New("invalid email format")
}
return req, nil
}
func checkUserExists(req RegistrationRequest) (bool, error) {
exists, err := Database.UserExists(req.Email)
if err != nil {
return false, err
}
return exists, nil
}
func hashPassword(req RegistrationRequest) (RegistrationRequest, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return RegistrationRequest{}, err
}
req.Password = string(hashedPassword)
return req, nil
}
func createUserProfile(req RegistrationRequest) (*User, error) {
user := &User{
Email: req.Email,
Password: req.Password,
Name: req.Name,
CreatedAt: time.Now(),
}
err := Database.InsertUser(user)
if err != nil {
return nil, err
}
return user, nil
}
func sendWelcomeEmail(user *User) error {
return EmailService.SendWelcome(user.Email, user.Name)
}
func buildSuccessResponse(user *User) RegistrationResponse {
return RegistrationResponse{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Message: "User registered successfully",
}
}
And to be clear I don’t think the Go implementation is bad, but the Elixir one is a thing of beauty.
Key Differences
1. Pattern Matching vs Manual Validation
- Elixir: Function heads automatically handle different input patterns
- Go: Manual validation with explicit error checking
2. Piping vs Sequential Calls
-
Elixir:
data |> func1() |> func2() |> func3()
-
Go:
result1, err := func1(data); if err != nil { return err }; result2, err := func2(result1)...
- verbose error handling
3. With Clauses vs Manual Error Propagation
-
Elixir:
with
clauses handle complex conditional flows elegantly - Go: Manual error checking at each step with repetitive patterns
4. Data Transformation
- Elixir: Immutable data flows through transformations naturally
- Go: Mutable structs or creating new instances at each step
The Elixir version is not just shorter - it’s more readable, maintainable, and follows the principle of “let it crash” with proper error handling through pattern matching.