ExDatalog

A production-grade Datalog engine for Elixir.

ExDatalog implements bottom-up Datalog evaluation using semi-naive fixpoint computation. Programs are built with a declarative builder API, validated, compiled to an engine-neutral IR, and evaluated by a pluggable backend.

Hex.pmHex.pmHex.pmHexDocs.pmCoverage Status

New to Datalog? Read the What is Datalog? guide for a comprehensive introduction covering history, concepts, industry use cases, and how Datalog can serve as a knowledge layer for LLMs.

Why Datalog Matters

Datalog remains one of the most elegant ways to express:

It continues to influence modern databases, compilers, static analysis tools, knowledge graphs, authorization systems, and AI reasoning engines.

Features

Installation

Add ex_datalog to your dependencies in mix.exs:

def deps do
[
{:ex_datalog, "~> 0.5.0"}
]
end

Quick Start

DSL (Schema macro)

The recommended way to define Datalog programs in v0.4.0+:

defmodule AncestorRules do
use ExDatalog.Schema
relation :parent do
field :parent, :atom
field :child, :atom
end
relation :ancestor do
field :ancestor, :atom
field :descendant, :atom
end
fact parent(:alice, :bob)
fact parent(:bob, :carol)
fact parent(:carol, :dave)
rule ancestor(X, Y) do
parent(X, Y)
end
rule ancestor(X, Z) do
parent(X, Y)
ancestor(Y, Z)
end
query :descendants_of_alice do
find Y
where ancestor(:alice, Y)
end
end
{:ok, knowledge} = AncestorRules.materialize()
AncestorRules.query(:descendants_of_alice, knowledge)
#=> [:bob, :carol, :dave]

Uppercase identifiers in rule bodies are logic variables. Constants use atom syntax (:alice). Use _ or wildcard() for wildcards. Negation uses not_:

rule bachelor(P) do
male(P)
not_ married(P, _)
end

Constraints use named predicates:

rule high_earner(P) do
income(P, S)
gt(S, 100_000)
end

The builder API (Program.add_rule, Program.add_fact, etc.) remains fully supported as the lower-level interface.

Builder API

Transitive closure

The classic Datalog example: compute all ancestors from parent facts.

alias ExDatalog
alias ExDatalog.{Program, Knowledge}
{:ok, knowledge} =
Program.new()
|> Program.add_relation("parent", [:atom, :atom])
|> Program.add_relation("ancestor", [:atom, :atom])
|> Program.add_fact("parent", [:alice, :bob])
|> Program.add_fact("parent", [:bob, :carol])
|> Program.add_fact("parent", [:carol, :dave])
|> Program.add_rule(
{"ancestor", [:X, :Y]},
[{:positive, {"parent", [:X, :Y]}}]
)
|> Program.add_rule(
{"ancestor", [:X, :Z]},
[
{:positive, {"parent", [:X, :Y]}},
{:positive, {"ancestor", [:Y, :Z]}}
]
)
|> ExDatalog.materialize()
Knowledge.get(knowledge, "ancestor")
#=> MapSet.new([{:alice, :bob}, {:bob, :carol}, {:carol, :dave},
#=> {:alice, :carol}, {:bob, :dave}, {:alice, :dave}])

Shorthand rule notation

add_rule/3 and add_rule/4 use a tuple-based shorthand that follows Prolog convention: uppercase atoms become variables, :_ becomes a wildcard, and lowercase atoms/other values become constants.

# Base rule: ancestor(X,Y) :- parent(X,Y).
Program.add_rule(program, {"ancestor", [:X, :Y]}, [{:positive, {"parent", [:X, :Y]}}])
# With constraints — find high earners:
Program.add_rule(program, {"high_earner", [:X]}, [{:positive, {"income", [:X, :S]}}], [{:gt, :S, 100_000}])
# Negation — bachelors are males who are not married:
Program.add_rule(program, {"bachelor", [:X]}, [
{:positive, {"male", [:X]}},
{:negative, {"married", [:X, :_]}}
])

The struct-based add_rule/2 with Rule.new/3 remains available for full control over term types.

Arithmetic constraints

Compute derived values in rules. Find numbers and their doubles:

{:ok, knowledge} =
Program.new()
|> Program.add_relation("number", [:integer])
|> Program.add_relation("doubled", [:integer, :integer])
|> Program.add_fact("number", [1])
|> Program.add_fact("number", [2])
|> Program.add_fact("number", [3])
|> Program.add_rule(
{"doubled", [:X, :Y]},
[{:positive, {"number", [:X]}}],
[{:add, :X, 2, :Y}]
)
|> ExDatalog.materialize()
Knowledge.get(knowledge, "doubled")
#=> MapSet.new([{1, 3}, {2, 4}, {3, 5}])

Type predicates and membership

Filter bindings by Elixir type or list membership:

# Keep only integer values from a mixed-type relation
Program.add_rule(program, {"int_value", [:N, :V]},
[{:positive, {"value", [:N, :V]}}],
[{:is_integer, :V}]
)
# Keep only "primary" colors
Program.add_rule(program, {"primary_color", [:X]},
[{:positive, {"color", [:X]}}],
[{:member, :X, [:red, :blue, :green]}]
)
# Keep only strings that start with "hel"
Program.add_rule(program, {"hello_word", [:X]},
[{:positive, {"word", [:X]}}],
[{:starts_with, :X, "hel"}]
)

Aggregates

Group and reduce bindings with count, sum, min, max:

defmodule DeptStats do
use ExDatalog.Schema
relation :emp do
field(:name, :atom)
field(:dept, :atom)
end
relation :dept_count do
field(:dept, :atom)
field(:n, :integer)
end
fact(emp(:alice, :eng))
fact(emp(:bob, :eng))
fact(emp(:carol, :sales))
rule dept_count(D, N) do
emp(_, D)
count(E, N)
end
end
{:ok, k} = DeptStats.materialize()
Knowledge.get(k, "dept_count")
#=> MapSet.new([{:eng, 2}, {:sales, 1}])

BEAM callback predicates

Call deterministic Elixir functions from rules:

defmodule Gated do
use ExDatalog.Schema
relation :user do
field(:name, :atom)
field(:age, :integer)
end
relation :active_user do
field(:name, :atom)
end
predicate(:adult?, AgeChecker, :adult?, [:integer], :boolean)
fact(user(:alice, 30))
fact(user(:bob, 12))
rule active_user(U) do
user(U, Age)
adult?(Age)
end
end
defmodule AgeChecker do
def adult?(age), do: age >= 18
end
{:ok, k} = Gated.materialize()
Knowledge.get(k, "active_user")
#=> MapSet.new([{:alice}])

Value-returning callbacks bind a result variable:

predicate :length_of, StringLength, :compute, [:atom], :value

Magic sets (experimental)

Compute only facts relevant to a goal, instead of the full fixpoint:

{:ok, k} = ExDatalog.materialize(program,
strategy: :magic_sets,
goal: {"ancestor", [:alice, :_]}
)

Unsupported programs (negation, aggregates) automatically fall back to semi-naive evaluation.

Negation

Use negative body atoms with stratified evaluation. Find people who are not parents:

Program.add_rule(program, {"childless", [:X]}, [
{:positive, {"person", [:X]}},
{:negative, {"parent", [:X, :_]}}
])

ETS backend

For workloads exceeding ~100K facts, use the ETS backend for off-heap storage and reduced GC pressure:

{:ok, knowledge} = ExDatalog.materialize(program, storage: ExDatalog.Storage.ETS)
# Or with options:
{:ok, knowledge} =
ExDatalog.materialize(program,
storage: ExDatalog.Storage.ETS,
storage_opts: [access: :public, write_concurrency: true]
)

Provenance / explain

Track which rule derived each fact:

{:ok, knowledge} = ExDatalog.materialize(program, explain: true)
knowledge.provenance.fact_origins
#=> %{"ancestor" => %{{:alice, :bob} => "rule_0", ...}, ...}

Telemetry

ExDatalog emits :telemetry events at the start, end, and on exceptions during materialization:

:telemetry.attach("my-handler", [:ex_datalog, :materialize, :stop], &handle_stop/4, nil)
def handle_stop(_event, measurements, metadata, _config) do
IO.puts("Query completed in #{measurements.duration} µs (#{measurements.iterations} iterations)")
IO.puts("Storage backend: #{metadata.storage_type}")
end

Architecture

ExDatalog.Program (builder) / ExDatalog.Schema (DSL)
|
v
ExDatalog.Validator (structural + semantic + stratification)
|
v
ExDatalog.Compiler (AST -> IR)
|
v
ExDatalog.Planner (strategy, strata, joins)
|
v
ExDatalog.Engine (behaviour)
|
v
ExDatalog.Engine.Naive (semi-naive fixpoint / magic-sets)
|
v uses
ExDatalog.Storage.Map | ExDatalog.Storage.ETS
|
v
ExDatalog.Knowledge

The Storage behaviour defines the contract for pluggable backends. Storage.Map uses on-heap Maps and MapSets; Storage.ETS uses per-relation ETS tables with wrapped-tuple keys for O(1) membership and correct set semantics. Both guarantee deterministic output order from stream/2, so identical programs produce identical results on either backend.

Constraint evaluation dispatches through ExDatalog.Constraint.evaluate/3, which routes to the appropriate module (Comparison, Arithmetic, Type, StringPredicate, Membership) based on the constraint's op field. The evaluation context carries the storage backend's capabilities, enabling future constraint types to inspect them.

Storage backends

BackendStorageBest forConcurrent reads
Storage.MapOn-heap Maps/MapSets<100K factsNo
Storage.ETSPer-relation ETS tables>100K facts, GC reliefYes (with access: :public)

Both backends produce identical output for the same program and facts. The portability test suite verifies this for transitive closure, arithmetic, comparison, negation, type predicates, string predicates, and membership constraints.

See docs/storage_backends.md for the full backend contract, options, and capability model.

Constraints

See docs/constraints.md for the complete constraint reference.

CategoryOpsBinds result?
Comparisongt, lt, gte, lte, eq, neqNo (filter)
Arithmeticadd, sub, mul, divYes
Type predicatetype_integer, type_binary, type_atomNo (filter)
String predicatestarts_with, containsNo (filter)
MembershipmemberNo (filter)
Aggregatecount, sum, min, maxYes (group-and-reduce)

Documentation

Generate docs locally:

mix docs

Further Reading on Datalog

Datalog sits at the intersection of databases, logic programming, graph reasoning, and knowledge systems.
The following references are highly recommended for understanding both the theory and practice behind modern Datalog systems.

Modern Datalog Systems


Foundational Theory


Talks and Presentations


Roadmap

VersionDescription
v0.3.0Tuple shorthand for rules (add_rule/3, add_rule/4), Term.from/1, ExDatalog.Atom.from_tuple/1, Constraint.from_tuple/1; renamed ResultKnowledge, querymaterialize
v0.4.0Schema DSL (use ExDatalog.Schema), relation, fact, rule, query macros, not_ negation, constraint DSL, post-materialization queries
v0.4.1DSL review fixes: correct uppercase-variable docs, clean aggregate error, query/find validation, unified DSL.CompileError, 792 tests
v0.5.0Aggregates (count/sum/min/max), BEAM callback predicates, query planner, magic sets (experimental), runtime facts API, 871 tests
v1.0.0Stable public API, hardened production semantics

License

MIT