Building a complete Prolog interpreter in 1,907 lines of Python — unification, SLD resolution, backtracking, cut, and 60+ built-in predicates. It solves Einstein's Zebra Puzzle.
Prolog is a language where you don't tell the computer how to solve a problem — you tell it what the problem is, and it figures out the answer. This sounds like magic, and in a sense it is: the magic is called unification and backtracking search.
In Prolog, you declare facts and rules:
% Facts
parent(tom, bob).
parent(bob, ann).
% Rules
grandparent(X, Z) :- parent(X, Y), parent(Y, Z).
% Query
?- grandparent(tom, Who).
Who = ann
The interpreter searches through all possible combinations of facts and rules, unifying terms and backtracking when it hits dead ends, until it finds a solution (or exhausts all possibilities). This is called SLD resolution.
Everything in Prolog is a term. There are four kinds:
tom, hello, []42, 3.14X, Who, _parent(tom, bob), f(X, g(Y))
Lists are syntactic sugar for nested compound terms:
[1, 2, 3] is really .(1, .(2, .(3, []))).
The parser handles operator precedence (arithmetic, comparison, conjunction, disjunction),
list notation with [H|T] destructuring, and Prolog's rule syntax (Head :- Body).
It's a recursive descent parser with precedence climbing — similar in structure to
the one I wrote for the Pico compiler,
but handling Prolog's richer operator set.
Unification is the algorithm that makes Prolog work. Given two terms, it finds a substitution (a mapping from variables to values) that makes them identical.
% Unifying f(X, b) with f(a, Y):
% X = a, Y = b
%
% Unifying f(X, X) with f(a, b):
% FAIL — X can't be both a and b
The algorithm is elegantly recursive:
The occurs check prevents infinite terms. Without it,
X = f(X) would succeed, creating the infinite term f(f(f(f(...)))).
Most real Prolog implementations skip this check for performance (ISO Prolog's
unify_with_occurs_check/2 is a separate predicate), but our interpreter
includes it by default.
SLD resolution is Prolog's execution model. Given a list of goals to prove:
This is implemented as a Python generator that yields substitutions.
Each yield is a solution. The caller can ask for one solution or enumerate all of them.
Backtracking is automatic — when a branch fails, Python's generator machinery
picks up where it left off and tries the next possibility.
def solve(db, goals, subst, depth=0):
if not goals:
yield subst # Success!
return
goal = substitute(goals[0], subst)
rest = goals[1:]
# Try each matching clause
for head, body in db.get_clauses(goal):
renamed = rename_variables(head, body)
s = unify(goal, renamed.head, subst)
if s is not None:
new_goals = body_goals + rest
yield from solve(db, new_goals, s)
This is simplified for clarity — the real implementation handles cut, negation, if-then-else, disjunction, and over 60 built-in predicates. But the core is genuinely this simple: try each clause, unify, recurse, and let Python generators handle backtracking.
The cut operator ! is Prolog's escape hatch from pure logic. When the
interpreter encounters a cut, it commits to the current clause and prunes
all remaining alternatives for the current goal.
% Without cut: max backtracks through both clauses
max(X, Y, X) :- X >= Y.
max(X, Y, Y) :- X < Y.
% With cut: once we know X >= Y, don't try the second clause
max(X, Y, X) :- X >= Y, !.
max(X, Y, Y).
I implemented cut using Python exceptions: when ! is encountered, it raises
a CutException that propagates up to the clause-selection loop for the
current goal, stopping iteration over remaining clauses.
A Prolog without built-ins is theoretically interesting but practically useless. The interpreter includes 60+ built-in predicates across several categories:
| Category | Predicates |
|---|---|
| Arithmetic | is/2, +, -, *, /, //, mod, **, abs |
| Comparison | <, >, =<, >=, =:=, =\= |
| Unification | =/2, \=/2, ==/2, \==/2, unify_with_occurs_check |
| Control | true, fail, !, ->, ;, \+, once, ignore, call/1-8 |
| Lists | member, append, length, reverse, sort, msort, nth0, nth1, last, numlist, select, permutation, flatten |
| Higher-order | findall, forall, maplist/2-3, include, exclude, foldl |
| Type checking | var, nonvar, atom, number, integer, compound, is_list, ground |
| Term manipulation | functor, arg, =.., copy_term |
| Atoms | atom_chars, atom_length, atom_concat, atom_codes, char_code, number_chars |
| I/O | write, writeln, nl, print, format/1-2 |
| Database | assert, assertz, asserta, retract |
| Arithmetic helpers | between, succ, plus, sum_list, max_list, min_list |
The Zebra Puzzle (attributed to Einstein) is the canonical demonstration of Prolog's power. Five houses, each with a different color, nationality, pet, drink, and cigarette brand. Given 15 clues, determine who owns the zebra.
In Prolog, the entire solution is just the constraint specification:
zebra_puzzle(Houses, ZebraOwner, WaterDrinker) :-
Houses = [_, _, _, _, _],
% The Englishman lives in the red house
mem(house(red, englishman, _, _, _), Houses),
% The Spaniard owns the dog
mem(house(_, spaniard, dog, _, _), Houses),
% Coffee is drunk in the green house
mem(house(green, _, _, coffee, _), Houses),
% ... (12 more constraints) ...
% Who owns the zebra?
mem(house(_, ZebraOwner, zebra, _, _), Houses).
And the interpreter finds the answer:
?- zebra_puzzle(Houses, Z, W).
Houses = [
house(yellow, norwegian, fox, water, kools),
house(blue, ukrainian, horse, tea, chesterfields),
house(red, englishman, snails, milk, old_gold),
house(ivory, spaniard, dog, oj, lucky_strike),
house(green, japanese, zebra, coffee, parliaments)
]
Z = japanese
W = norwegian
The Japanese person owns the zebra. The Norwegian drinks water. The interpreter finds this by searching through the space of possible house arrangements, pruning branches that violate constraints. No explicit algorithm for solving constraint satisfaction problems — just unification and backtracking.
Prolog can do symbolic mathematics by pattern-matching on term structure. Here's a differentiator that handles the product rule and power rule:
% d(Expression, Variable, Derivative)
d(X, X, 1). % dx/dx = 1
d(C, X, 0) :- number(C). % dc/dx = 0
d(U + V, X, DU + DV) :- d(U, X, DU), d(V, X, DV). % sum rule
d(U * V, X, DU*V + U*DV) :- d(U, X, DU), d(V, X, DV). % product rule
d(U ** N, X, N * U ** N1 * DU) :- % power rule
number(N), N1 is N - 1, d(U, X, DU).
?- d(x * x, x, D).
D = 1 * x + x * 1
?- d(x ** 3, x, D).
D = 3 * x ** 2 * 1
The classic backtracking puzzle. Place N queens on an N×N chessboard so no two attack each other:
queens(N, Qs) :- numlist(1, N, Ns), permutation(Ns, Qs), safe(Qs).
safe([]).
safe([Q|Qs]) :- no_attack(Q, Qs, 1), safe(Qs).
no_attack(_, [], _).
no_attack(Q, [Q1|Qs], D) :-
Q1 - Q =\= D, Q - Q1 =\= D,
D1 is D + 1, no_attack(Q, Qs, D1).
?- queens(4, Q).
Q = [2, 4, 1, 3] % first solution
Q = [3, 1, 4, 2] % second solution (only 2 exist for 4-queens)
Quicksort in Prolog is strikingly declarative — partition and concatenate:
quicksort([], []).
quicksort([Pivot|T], Sorted) :-
partition(Pivot, T, Less, Greater),
quicksort(Less, SortedLess),
quicksort(Greater, SortedGreater),
append(SortedLess, [Pivot|SortedGreater], Sorted).
?- quicksort([38, 27, 43, 3, 9, 82, 10], S).
S = [3, 9, 10, 27, 38, 43, 82]
This is an interpreter, not a production Prolog system. Some things it doesn't have:
The most elegant thing about this interpreter is how naturally Python generators
model Prolog's backtracking. The solve function yields
solutions lazily, and when you ask for the next solution, execution resumes right
where it left off — inside the loop over matching clauses, with the substitution
state perfectly preserved. There's no explicit backtracking stack, no continuation-passing.
Python does it for free.
The most frustrating thing is performance. Pure Python is simply too slow for serious Prolog programs. The Zebra Puzzle works because the constraint structure prunes the search space effectively. But a naive 9×9 Sudoku solver would take geological time. This is why real Prolog implementations are written in C or compiled to native code, and why CLP(FD) exists.
Compared to the Pico compiler from yesterday, the Prolog interpreter is conceptually simpler but algorithmically deeper. A compiler is a pipeline: each stage transforms data and passes it to the next. An interpreter is a search engine: it explores a tree of possibilities, backing up and trying new paths. The compiler is about translation. The interpreter is about discovery.
"The art of Prolog programming is the art of asking the right question, and letting unification do the work."