Wednesday, September 17, 2025

Falling in Love (Platonically) With Ecto Relationships

Nick Hallam
Elixir, Ecto
404

If you’ve ever woken up at 2 a.m. in a cold sweat, whispering “LEFT OUTER JOIN… ON… something…”, then congratulations: you’ve written SQL by hand. For the rest of us who like sleep (and readable code), Elixir’s Ecto gives us relationships (has_many, belongs_to, has_one) and the magical land of preloads.

Let’s talk about why these make your life easier, your queries cleaner, and your hair slightly less gray.


Relationships: Because Tables Don’t Exist in a Vacuum

In SQL, relationships exist in your head, your ERD diagrams, and in the nightmares of your junior devs. In Ecto, they’re declared once in your schema and then reused forever.

Example:

defmodule Blog.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string

    has_many :posts, Blog.Post

    timestamps()
  end
end

defmodule Blog.Post do
  use Ecto.Schema

  schema "posts" do
    field :title, :string
    field :body, :string

    belongs_to :user, Blog.User

    timestamps()
  end
end

Now Ecto knows what belongs to what. You don’t need to remember which foreign key goes where every time. Imagine that: your codebase can be smarter than the sticky note on your monitor.


Preloads: Lazy but in a Good Way

By default, Ecto is a bit lazy — if you fetch a user, it won’t automatically fetch their posts. That’s intentional: no one wants an accidental N+1 query apocalypse.

So you preload explicitly:

users = Repo.all(from u in Blog.User, preload: [:posts])

for user <- users do
  IO.puts("#{user.name} has #{length(user.posts)} posts")
end

No manual joins, no copy-pasting SELECT * FROM posts WHERE user_id = ? like it’s 2003. Just clean data, neatly bundled into your structs.


Why Not Just Use Joins Like the Old Days?

Sure, you can still join things the hard way:

query =
  from u in Blog.User,
    join: p in Blog.Post, on: p.user_id == u.id,
    preload: [posts: p]

Repo.all(query)

But let’s be honest: every join you handcraft makes your query harder to maintain, harder to read, and harder to explain to your future self.

The power of Ecto is that it separates loading associations (joins, subqueries, etc.) from declaring relationships. You define once, then preload as needed. SQL purists might cry, but SQL purists also cry about semicolons.


Superiority Checklist

  • Maintainability: Relationships declared in schemas live forever; no more “which foreign key is this again?”
  • Readability: Preloads say exactly what you mean: “load the posts too.” It’s basically English.
  • Performance: Explicit preloads prevent N+1s and give you control.
  • Safety: Ecto sanitizes queries and protects against injection. Unless, of course, you decide fragment("DROP DATABASE") is a fun afternoon activity.

Further Reading (because no one trusts blog posts alone)


Closing Thought

SQL is powerful, but so is C. Do you want to write in C every day? Or would you like a nice abstraction where user.posts just works? With Ecto, relationships aren’t just lines in an ERD diagram — they’re first-class citizens in your codebase.

And that, my friends, is one relationship worth committing to.