Prolog Essentials
A hands-on introduction to logic programming with Prolog, running entirely in your browser. Learn to think declaratively — define facts, write rules, and let Prolog find the answers.
Facts and Queries
Why this matters
You already know Python, JavaScript, and C++. In all three, you tell the computer how to solve a problem — step by step, with loops, variables, and function calls. Prolog is fundamentally different: you describe what is true, and Prolog figures out the answers. This shift from imperative recipes to declarative descriptions is the single most important idea in this tutorial — and the foundation for everything that follows.
🎯 You will learn to
- Apply Prolog facts to record relationships in a knowledge base
- Analyze query results to see how the same predicate works in multiple directions
Facts: Declaring what is true
A fact states something unconditionally:
parent(tom, bob).
This says: “Tom is a parent of Bob.” Every fact ends with a period.
There is no return statement. No variables being assigned. You are simply recording a relationship.
Queries: Asking questions
Use the ?- input to ask Prolog questions:
| Query | Meaning | Expected answer |
|---|---|---|
parent(tom, bob) |
“Is Tom a parent of Bob?” | true |
parent(tom, X) |
“Who are Tom’s children?” | X = bob |
parent(X, bob) |
“Who are Bob’s parents?” | X = tom |
Notice that the same predicate works in multiple directions. This is not a function with fixed inputs and outputs — it’s a relation that Prolog can query from any angle.
Imperative trap: In Python,
parent(tom, bob)would be a function call returning a value. In Prolog, it’s a question — and the answer is yes/no, plus any variable bindings.
Your Task
The editor has one fact. Add two more:
parent(bob, ann).parent(bob, pat).
Then try these queries (one at a time in the ?- input):
parent(tom, bob)— yes/no questionparent(tom, X)— “who” questionparent(X, ann)— reverse direction!parent(X, Y)— enumerate all parent relationships
% Family database % A fact parent(X, Y) means "X is a parent of Y". parent(tom, bob). % TODO: Add two more facts: % parent(bob, ann). % parent(bob, pat).
Step 1 — Knowledge Check
Min. score: 80%1. Given these facts:
likes(alice, pizza).
likes(bob, pasta).
likes(alice, sushi).
likes(alice, X) return?
Prolog finds all facts that match the query pattern. Since Alice likes both pizza and sushi, both are returned. This is fundamentally different from a function call that returns one value.
2. In Python, parent(tom, bob) is a function call. In Prolog, it is:
Prolog predicates are relations, not functions. parent(tom, bob) asks: “does the knowledge base contain this relationship?” There is no return value — Prolog either succeeds or fails.
Unification: = Is Not Assignment
Why this matters
In Python, X = 5 stores the value 5 in a variable named X. In Prolog, X = 5 means something completely different — it’s bidirectional pattern matching, not assignment. Mis-reading = as assignment is the single most common source of confusion when programmers transfer in from imperative languages, and it will silently break any code that uses arithmetic, structures, or recursion. Get this concept right and the rest of Prolog falls into place.
🎯 You will learn to
- Analyze whether two Prolog terms unify and what bindings result
- Evaluate why
X = 3, X = 4fails (single-assignment) where Python would simply reassign
Unification: bidirectional pattern matching
The = operator in Prolog asks: “Can these two terms be made identical?”
?- X = 5. % Yes: X becomes 5
?- 5 = 5. % Yes: identical
?- 5 = 6. % No: cannot make 5 equal to 6
?- f(X) = f(3). % Yes: X becomes 3
?- f(X, b) = f(a, Y). % Yes: X = a, Y = b
This is called unification — the engine of Prolog. It works by matching the structure of two terms and finding variable bindings that make them equal.
Variables are NOT mutable boxes
Imperative trap: In Python, you can write
x = 3thenx = 4. In Prolog, once a variable is bound, it cannot be changed:
?- X = 3, X = 4. % FAILS! X is already 3, and 3 ≠ 4.
This is called single assignment — a variable gets one value per query. If you need a different value, you need a different variable or a different branch of the search.
Unification with structures
Prolog terms can be nested structures (like trees):
?- date(Day, march, 2024) = date(15, Month, Year).
% Day = 15, Month = march, Year = 2024
Both sides are made identical by binding variables to values. Neither side is “input” or “output” — unification is symmetric.
Your Task
The file contains several unification queries as facts. For each one, predict whether it will succeed or fail, then uncomment it and run it to check.
% Unification exercises
% For each, PREDICT: will it succeed or fail? What are the bindings?
% Then uncomment and query: test1, test2, etc.
% Example: X = hello succeeds with X = hello
test_example :- X = hello, write('X = '), write(X), nl.
% 1. Does this succeed? What is X?
test1 :- X = f(a, b), write('X = '), write(X), nl.
% 2. Does this succeed? What are X and Y?
test2 :- f(X, b) = f(a, Y), write('X = '), write(X), write(', Y = '), write(Y), nl.
% 3. Does this succeed or FAIL?
test3 :- a = b, write('This should not print'), nl.
% 4. Does this succeed or FAIL? (tricky!)
test4 :- X = 3, X = 3, write('X = '), write(X), nl.
% 5. Does this succeed or FAIL?
test5 :- X = 3, X = 4, write('This should not print'), nl.
% Run all tests:
test_unify :-
write('--- Test 1 ---'), nl, (test1 -> true ; write('FAILED'), nl),
write('--- Test 2 ---'), nl, (test2 -> true ; write('FAILED'), nl),
write('--- Test 3 ---'), nl, (test3 -> true ; write('FAILED'), nl),
write('--- Test 4 ---'), nl, (test4 -> true ; write('FAILED'), nl),
write('--- Test 5 ---'), nl, (test5 -> true ; write('FAILED'), nl).
Step 2 — Knowledge Check
Min. score: 80%
1. What happens when Prolog processes X = 3, X = 4?
Once X is bound to 3, X = 4 attempts to unify 3 with 4, which fails. Prolog variables are single-assignment within a query. This is the #1 source of confusion for imperative programmers.
2. Does point(X, 2) = point(1, Y) succeed? If so, what are X and Y?
Unification matches corresponding arguments: X unifies with 1, and 2 unifies with Y. Both sides become point(1, 2). Prolog doesn’t need point to be “defined” — it’s just a structure.
3. How does Prolog’s = differ from Python’s =?
Python’s = always assigns the right side to the left. Prolog’s = finds bindings that make both sides identical — it works symmetrically. f(X) = f(3) and f(3) = f(X) both bind X to 3.
Rules: Deriving New Knowledge
Why this matters
Facts let you record what you already know. Rules let Prolog derive new knowledge you never wrote down — relationships like grandparent, ancestor, or sibling follow logically from the facts you have. This is where Prolog earns its keep over a database: you write the logic, and Prolog handles the search for matching values. No if/else, no loops — just declarative implications.
🎯 You will learn to
- Apply the
:-operator to define rules that compose existing predicates - Analyze how Prolog searches for variable bindings that satisfy a rule’s body
Rule syntax
grandparent(X, Z) :- parent(X, Y), parent(Y, Z).
Read :- as “if” and , as “and”:
X is a grandparent of Z if X is a parent of some Y and Y is a parent of Z.
The left side is the head (what we’re defining). The right side is the body (the conditions).
How Prolog uses rules
When you query grandparent(tom, ann), Prolog:
- Finds the rule for
grandparent - Tries to satisfy the body: find a Y where
parent(tom, Y)ANDparent(Y, ann) - Tries
Y = bob: isparent(tom, bob)true? Yes. Isparent(bob, ann)true? Yes. - Both conditions met — the query succeeds.
Imperative trap: There is no
if/else, no loop. Prolog tries all possibilities automatically. You declare the logic, and Prolog handles the search.
Variables in rules
Variables in rules (like X, Y, Z) are universally quantified — they stand for any value that makes the rule work. When Prolog uses a rule, it creates fresh copies of all variables for that specific use.
Your Task
Define two rules:
grandparent(X, Z)— as shown abovesibling(X, Y)— X and Y are siblings if they share a parent, but are not the same person. UseX \= Yfor “not equal”.
% Family database parent(tom, bob). parent(bob, ann). parent(bob, pat). % Rule: grandparent % TODO: Define grandparent(X, Z) :- ... % Rule: sibling % Two people are siblings if they share a parent % but are not the same person. % TODO: Define sibling(X, Y) :- ...
Step 3 — Knowledge Check
Min. score: 80%
1. Given parent(tom, bob) and parent(bob, ann), what does grandparent(tom, ann) do?
Even though there is no fact grandparent(tom, ann), Prolog uses the rule to derive it. It finds Y = bob that satisfies both conditions. Rules allow Prolog to answer questions that aren’t explicitly stated.
2. If we define sibling(X, Y) :- parent(P, X), parent(P, Y). (without X \= Y), what happens when we query sibling(ann, ann)?
Without the X \= Y check, ann would be her own sibling (bob is a parent of ann AND ann). This is logically correct but semantically wrong — we need the inequality constraint to exclude self-siblings.
Backtracking: The Search for Solutions
Why this matters
When Prolog encounters a query, it doesn’t just find one answer — it systematically searches for all answers, exploring every combination of variable bindings. This mechanism, called backtracking, is what replaces nested for loops, list comprehensions, and explicit search code in imperative languages. Once you can trace backtracking by hand, you can predict exactly which solutions Prolog returns and in what order — a prerequisite for writing correct recursive predicates later.
🎯 You will learn to
- Analyze a query trace step by step to predict which solutions Prolog returns and in what order
- Apply backtracking to enumerate all combinations of values across multiple predicates
The search process
Consider this knowledge base:
food(pizza).
food(pasta).
food(sushi).
drink(water).
drink(juice).
meal(F, D) :- food(F), drink(D).
Query: meal(X, Y) — “What are all possible meals?”
Prolog’s search:
- Try
food(pizza)— success! Now trydrink(water)— success! Answer: X=pizza, Y=water - Backtrack to try next drink:
drink(juice)— success! Answer: X=pizza, Y=juice - No more drinks. Backtrack to try next food:
food(pasta), then try drinks again… - Continue until all combinations are exhausted.
Result: 6 answers (3 foods x 2 drinks).
Imperative trap: In Python, you’d write nested
forloops. In Prolog, the “looping” happens automatically through backtracking. You never write loop syntax.
Controlling backtracking with ; (or)
The ; operator means “or”:
pet(X) :- cat(X) ; dog(X).
X is a pet if X is a cat OR X is a dog.
Your Task
The file defines a small meal planning database. Define the meal/2 predicate, then explore how backtracking produces multiple solutions.
Try these queries:
meal(X, Y)— all possible mealsmeal(pizza, Y)— what can you drink with pizza?meal(X, water)— what food goes with water?
% Foods and drinks food(pizza). food(pasta). food(sushi). drink(water). drink(juice). % TODO: Define meal(F, D) - a meal is a food paired with a drink % meal(F, D) :- ... % Bonus: Define healthy_meal/2 where the food is sushi % and the drink is water. % healthy_meal(F, D) :- ...
Step 4 — Knowledge Check
Min. score: 80%
1. How many answers does meal(X, Y) produce, given 3 foods and 2 drinks?
Prolog tries every combination: each food with each drink. With 3 foods and 2 drinks, that’s 3 x 2 = 6 meals. This is like nested for-loops, but Prolog does it automatically through backtracking.
2. In Python, to get all food-drink pairs you’d write:
for f in foods:
for d in drinks:
print(f, d)
In Prolog, you define the relationship (a meal is a food plus a drink), then query it. Prolog’s backtracking mechanism produces all combinations automatically. No explicit loops needed.
Arithmetic: is vs =
Why this matters
Arithmetic in Prolog has a critical gotcha that trips up every imperative programmer: X = 2 + 3 does not compute 5 — it binds X to the unevaluated structure +(2, 3). To actually compute, you need the is/2 operator. Mixing these up produces predicates that silently bind variables to terms instead of numbers, leading to confusing failures further down the chain. Knowing exactly when to use = vs is is the gate to writing any Prolog program that touches numbers.
🎯 You will learn to
- Apply
is/2to evaluate arithmetic expressions and bind the result to a variable - Evaluate when to use
=,is, and=:=for the right kind of comparison or assignment
The trap: = does NOT evaluate
?- X = 2 + 3.
% X = 2+3 (NOT 5! It's the structure +(2,3))
Remember: = is unification. It makes two terms structurally identical. 2 + 3 is a structure (like a tree node with + as the functor), not a computation.
The solution: is/2 forces evaluation
?- X is 2 + 3.
% X = 5 (evaluates the right side, then unifies)
is evaluates the arithmetic expression on the right side, then unifies the result with the left side. The right side must be fully instantiated (no unbound variables).
Comparison operators
| Operator | Meaning | Example |
|---|---|---|
=:= |
Arithmetic equality | 2+3 =:= 5 succeeds |
=\= |
Arithmetic inequality | 2+3 =\= 6 succeeds |
< |
Less than | 3 < 5 succeeds |
> |
Greater than | 5 > 3 succeeds |
>= |
Greater or equal | 5 >= 5 succeeds |
=< |
Less or equal | 3 =< 5 succeeds |
Note: =< not <=! This is a common Prolog syntax surprise.
Your Task
Define these predicates:
bmi(Height, Weight, BMI)— BMI is Weight / Height^2. Useisto compute it.is_overweight(Height, Weight)— succeeds if BMI > 25.abs_diff(X, Y, D)— D is the absolute difference between X and Y.
% BMI calculator % bmi(Height, Weight, BMI) - compute BMI = Weight / Height^2 % Hint: use ** for exponentiation: Height ** 2 % TODO: bmi(Height, Weight, BMI) :- BMI is ... % is_overweight(Height, Weight) - succeeds if BMI > 25 % TODO: is_overweight(Height, Weight) :- ... % abs_diff(X, Y, D) - D is the absolute difference |X - Y| % Hint: use two clauses, one for X >= Y and one for X < Y % TODO: abs_diff(X, Y, D) :- ...
Step 5 — Knowledge Check
Min. score: 80%
1. What does X = 2 + 3 bind X to?
The = operator is unification, not evaluation. It makes X structurally identical to 2 + 3, which is a compound term +(2, 3). To get 5, use X is 2 + 3.
2. What happens with X is Y + 1 when Y is unbound (not yet assigned a value)?
The is operator requires the right-hand side to be a ground arithmetic expression (all variables must be bound to numbers). If Y is unbound, Prolog throws an instantiation error.
Lists and Pattern Matching
Why this matters
Lists are Prolog’s fundamental container — almost every nontrivial Prolog program processes them. The [H|T] destructuring pattern is your primary tool for working with lists, and unlike Python’s slicing or JS array methods, it works bidirectionally: the same pattern that decomposes a list can also build one. Mastering head/tail patterns now (without recursion yet) makes the recursive predicates in the next step feel natural instead of magical.
🎯 You will learn to
- Apply
[H|T]patterns to destructure a list into head and tail - Analyze nested patterns like
[X, Y | Rest]to grab specific positions without writing code[a, b, c] % a list of three atoms [1, 2, 3] % a list of three numbers [] % the empty list
But how you work with them is very different from Python/JS.
Head|Tail decomposition
The [H|T] pattern splits a list into its first element (head) and the rest (tail):
?- [H|T] = [a, b, c].
% H = a, T = [b, c]
This is Prolog’s equivalent of destructuring — but powered by unification:
?- [X, Y | Rest] = [1, 2, 3, 4, 5].
% X = 1, Y = 2, Rest = [3, 4, 5]
Transfer from Python: This is similar to Python’s
head, *tail = [1, 2, 3]. But in Prolog, it works bidirectionally — you can also build lists this way.
Writing list predicates (non-recursive)
% first(X, List) - X is the first element of List
first(X, [X|_]).
% second(X, List) - X is the second element
second(X, [_, X|_]).
The underscore _ means “I don’t care about this value.” Each _ is independent.
Your Task
Define these predicates using pattern matching (no recursion needed):
head(X, List)— X is the first element of Listtail(T, List)— T is the tail (everything except the first element)has_two([_, _])— succeeds only if the list has exactly two elementsswap_first_two(List, Swapped)— swap the first two elements:[a,b,c]becomes[b,a,c]
% head(X, List) - X is the first element % TODO: head(X, [... | ...]). % tail(T, List) - T is everything after the first element % TODO: tail(T, [... | ...]). % has_two(List) - succeeds only if List has exactly 2 elements % Hint: use the pattern [_, _] % TODO: has_two(...). % swap_first_two(List, Swapped) - swap first two elements % [a, b | Rest] becomes [b, a | Rest] % TODO: swap_first_two([... | ...], [... | ...]).
Step 6 — Knowledge Check
Min. score: 80%
1. What does [H|T] = [42] bind H and T to?
A single-element list [42] is really [42|[]]. So H unifies with 42 and T unifies with the empty list []. The tail of a one-element list is always [].
2. What does [_, X | _] = [a, b, c, d] bind X to?
The first _ matches a, X matches b (the second element), and the trailing _ matches the rest [c, d]. Each _ is independent and can match anything.
Recursive List Processing
Why this matters
Prolog has no for or while loops — recursion is the only way to process lists of arbitrary length. Every recursive predicate follows the same skeleton: a base case for the empty list and a recursive case that strips the head and recurses on the tail. Once you internalize this base-case/recursive-case pattern, you can write member, length, append, reverse, map, filter, and most other list operations almost mechanically.
🎯 You will learn to
- Apply the base-case/recursive-case pattern to write recursive list predicates
- Create new predicates (
my_last,my_append,my_reverse) by following a worked example
The pattern: Base case + Recursive case
Every recursive predicate has two clauses:
% Sub-goal: Base case — empty list
my_length([], 0).
% Sub-goal: Recursive case — strip head, recurse on tail
my_length([_|T], N) :- my_length(T, N1), N is N1 + 1.
Transfer from Python: This is like:
def my_length(lst): if lst == []: # base case return 0 return 1 + my_length(lst[1:]) # recursive case
The key difference: Prolog’s version works in both directions!
Worked example: my_member/2
% Sub-goal: Base case — X is the head of the list
my_member(X, [X|_]).
% Sub-goal: Recursive case — X is in the tail
my_member(X, [_|T]) :- my_member(X, T).
Try my_member(b, [a, b, c]) — succeeds. Try my_member(X, [a, b, c]) — returns all elements!
Your Task
Using the my_member worked example as a model:
my_last(X, List)— X is the last element of List- Base case: the last element of a one-element list
[X]is X - Recursive case: the last element of
[_|T]is the last element of T
- Base case: the last element of a one-element list
my_append(L1, L2, L3)— L3 is L1 concatenated with L2- Base case: appending L to
[]gives L - Recursive case: appending
[H|T1]to L2 gives[H|T3]where T3 is T1 appended to L2
- Base case: appending L to
my_reverse(List, Reversed)— reverse a list- Base case: reverse of
[]is[] - Recursive case: reverse of
[H|T]is reverse of T appended with[H] - Hint: use your
my_appendpredicate!
- Base case: reverse of
% --- Worked example (provided) --- % my_member(X, List) - X is an element of List % Sub-goal: Base case — X is the head my_member(X, [X|_]). % Sub-goal: Recursive case — X is in the tail my_member(X, [_|T]) :- my_member(X, T). % --- Your turn: follow the same pattern --- % my_last(X, List) - X is the last element of List % Sub-goal: Base case — last of a one-element list % TODO: my_last(X, [X]). % Sub-goal: Recursive case — skip head, recurse on tail % TODO: my_last(X, [_|T]) :- my_last(X, T). % my_append(L1, L2, L3) - L3 is L1 concatenated with L2 % Sub-goal: Base case — appending to empty list % TODO: my_append([], L, L). % Sub-goal: Recursive case — move head to result % TODO: my_append([H|T1], L2, [H|T3]) :- my_append(T1, L2, T3). % my_reverse(List, Reversed) - reverse a list % Sub-goal: Base case — reverse of empty is empty % TODO: my_reverse([], []). % Sub-goal: Recursive case — reverse tail, append head % TODO: my_reverse([H|T], R) :- my_reverse(T, RT), my_append(RT, [H], R).
Step 7 — Knowledge Check
Min. score: 80%
1. How does my_member(b, [a, b, c]) succeed?
Prolog tries the first clause: does b unify with the head a? No. So it tries the second clause, which recurses on the tail [b, c]. Now the first clause tries: does b unify with b? Yes! The query succeeds.
2. What does my_append(X, Y, [1, 2, 3]) do?
Because my_append is a relation (not a function), Prolog can find ALL combinations: X=[], Y=[1,2,3]; X=[1], Y=[2,3]; X=[1,2], Y=[3]; X=[1,2,3], Y=[]. This is the power of multi-directional predicates!
Putting It All Together
Why this matters
Individual pieces — facts, rules, arithmetic, lists, recursion — are useful, but real Prolog programs combine them. This synthesis step has you compose every concept from the tutorial into a small grade analyzer that looks up data, computes statistics, and answers multi-directional queries. Building one program end-to-end is what consolidates the pieces into a working mental model you can transfer to your own problems.
🎯 You will learn to
- Create a multi-predicate Prolog program that composes facts, rules, arithmetic, and recursion
- Evaluate when a problem is well-suited to declarative logic programming
Challenge: Student Grade Analyzer
Build a knowledge base that stores student grades and provides useful queries:
Facts (provided):
student(alice, [88, 92, 75, 95]).
student(bob, [70, 65, 80, 72]).
student(carol, [95, 98, 92, 97]).
Each student has a name and a list of exam scores.
Your task — define these predicates:
sum_list(List, Sum)— compute the sum of a list of numbers- Base: sum of
[]is 0 - Recursive: sum of
[H|T]is H plus the sum of T
- Base: sum of
length_list(List, Len)— compute the length of a list- (Same pattern as
my_lengthfrom Step 6)
- (Same pattern as
average(Name, Avg)— a student’s average score- Look up the student’s scores, compute sum and length, divide
above_average(Name, Threshold)— succeeds if the student’s average is above Threshold
Then query:
average(alice, A)— What is Alice’s average?above_average(carol, 90)— Does Carol average above 90?above_average(Name, 80)— Which students average above 80?
% Student grade database student(alice, [88, 92, 75, 95]). student(bob, [70, 65, 80, 72]). student(carol, [95, 98, 92, 97]). % sum_list(List, Sum) - sum of all numbers in List % Sub-goal: Base case % TODO: sum_list([], 0). % Sub-goal: Recursive case % TODO: sum_list([H|T], Sum) :- sum_list(T, Rest), Sum is H + Rest. % length_list(List, Len) - number of elements in List % Sub-goal: Base case % TODO: length_list([], 0). % Sub-goal: Recursive case % TODO: length_list([_|T], Len) :- length_list(T, L1), Len is L1 + 1. % average(Name, Avg) - compute a student's average score % TODO: average(Name, Avg) :- ... % above_average(Name, Threshold) - true if average > Threshold % TODO: above_average(Name, Threshold) :- ...
Step 8 — Knowledge Check
Min. score: 80%1. To find all students with average above 80, an imperative programmer would write a loop. In Prolog, you:
You define the relationship (what it means to be above average), then query with a variable for Name. Prolog’s backtracking finds all students that satisfy the condition. No explicit iteration needed.
2. What makes above_average(Name, 80) work for finding ALL qualifying students?
When average(Name, Avg) tries to satisfy itself, it looks up student(Name, Scores). Prolog tries each student fact through backtracking. For each one where Avg > 80, it reports a solution. This is the declarative way to “search” — you describe what you want, and backtracking does the searching.