Common Prolog Mistakes Beginners Make and How to Avoid Them in 2025
Prerequisites and What You Should Know First
Before diving in, confirm you have the following in place:
- [ ] SWI-Prolog 9.x installed — download from swi-prolog.org. Version 9.0 or newer is assumed throughout.
- [ ] REPL accessible — run
swiplfrom your terminal and get the?-prompt. - [ ] Familiarity with facts, rules, and queries — you can write
parent(tom, bob).and query?- parent(tom, X). - [ ]
library(clpfd)available — comes bundled with SWI-Prolog 9.x; verify with?- use_module(library(clpfd)). - [ ] Optional: scryer-prolog — an alternative ISO-closer implementation; examples noted where behaviour differs.
Estimated time: 45 minutes to work through all examples.
Mistake 1: Using Cut (!/0) and If-Then (->)/2 and Losing Solutions
Cut looks like a performance tool. It commits the search, prunes alternatives, and makes predicates feel crisp and decisive. The problem is that it silently destroys intended solutions during backtracking — and Prolog's entire value proposition is relational search over multiple solutions.
Why cut seems like a good idea at first
Imagine writing max/3 — a predicate that gives the maximum of two integers. A beginner's first instinct:
% Broken: max/3 with cut loses solutions
max_broken(X, Y, X) :- X >= Y, !.
max_broken(_, Y, Y).
Query it in one direction and it seems fine:
?- max_broken(3, 2, M).
M = 3.
?- max_broken(3, 3, M).
M = 3.
But watch what happens when you try to use it relationally:
?- max_broken(X, 2, 3).
false. % Should succeed with X = 3!
The cut after X >= Y fires before X is instantiated, arithmetic blows up or the clause ordering kills the second solution. The predicate only works ground-first.
How !/0 silently destroys intended solutions
Monotonicity in plain English: if a goal succeeds for some input, adding more constraints should never make it fail for that same input. Cut breaks this because it depends on clause ordering — a procedural concept, not a logical one. Rename your clauses, swap their order, and the program changes meaning. That is a red flag in a declarative language.
The declarative alternative: dif/2 and if_/3
if_/3 from library(reif) (available in SWI-Prolog via pack_install(reif)) expresses conditional logic without destroying solutions. The signature is if_(Cond, Then, Else) where Cond is a reifiable goal — one that can be true, false, or deferred when variables are unbound.
% Correct: max/3 using CLP(FD) — works in all modes
:- use_module(library(clpfd)).
max_clp(X, Y, M) :-
M #= max(X, Y).
?- max_clp(3, 2, M).
M = 3.
?- max_clp(X, 2, 3).
X = 3.
?- max_clp(X, Y, 3), X in 1..5, Y in 1..5, label([X,Y]).
X = 1, Y = 3 ;
X = 2, Y = 3 ;
% ... all valid pairs
If you need if_/3 for non-arithmetic conditionals, load it and use =(X,Y,T) as the reifiable equality:
:- use_module(library(reif)).
classify(X, Class) :-
if_(X = 0, Class = zero, Class = nonzero).
This works whether X is ground or not — it will suspend and resume correctly.
Note:
library(reif)is not bundled with SWI-Prolog by default. Install it with?- pack_install(reif).at the REPL. In scryer-prolog it is available as a built-in library.
Mistake 2: Mutating the Global Database with assertz/1 and retract/1
Global mutable state is familiar from imperative languages. In Prolog, assertz/1 and retract/1 let you add and remove facts at runtime — and beginners reach for them immediately when they need a counter, a cache, or an accumulator.
What global state looks like in Prolog and why it feels familiar
% Broken counter using assertz/retract
:- dynamic counter/1.
counter(0).
increment :-
retract(counter(N)),
N1 is N + 1,
assertz(counter(N1)).
current_count(N) :- counter(N).
This works in isolation. But in a test suite or composed program:
?- increment, increment, current_count(N).
N = 2.
% Run again in the SAME session:
?- increment, increment, current_count(N).
N = 4. % State leaked from the previous query!
The counter persists across queries. Tests pass in isolation, fail when composed. This is the hidden ordering dependency: the predicate's behaviour depends on the global database state at the moment of the call.
Gotcha: A common nightmare scenario — your plunit tests pass individually but fail when run as a suite because
assertzfacts from one test bleed into the next. Always ask: does my predicate's result depend on something not in its argument list?
Threading state through predicate arguments instead
The fix is to make state explicit as predicate arguments — the accumulator pattern:
% Correct: accumulator-based counter
count_items([], Acc, Acc).
count_items([_|T], Acc, Final) :-
Acc1 is Acc + 1,
count_items(T, Acc1, Final).
count(List, N) :- count_items(List, 0, N).
?- count([a,b,c], N).
N = 3.
?- count([a,b,c], N), count([x,y], M).
N = 3, M = 2. % No shared state, no surprises.
Semicontext notation as a cleaner alternative
For more complex state threading, DCG semicontext notation keeps the code readable. Here is a stack implemented as a DCG:
% DCG with semicontext for stack operations
push(E, [E|S], S).
pop(E, S, [E|S]).
% Use in a DCG rule:
process_item(X) --> push(X), { format(atom(_), '', []) }.
The S0/S state pair is threaded implicitly by the DCG expansion, keeping your predicate heads clean.
Mistake 3: Using var/1 and nonvar/1 for Control Flow
var/1 checks whether its argument is an uninstantiated variable. It seems like a natural guard: "if I have a value, compute; otherwise, return a default." But it breaks the relational model in ways that are hard to debug.
How var/1 breaks the relational model
% Broken: type dispatch using var/1
describe(X, Type) :-
( var(X) -> Type = unknown
; integer(X) -> Type = integer
; Type = other
).
?- describe(42, T).
T = integer. % Fine.
?- describe(X, integer).
T = unknown. % Wrong! Should be able to infer X is an integer.
?- describe(X, T), X = 42.
T = unknown, X = 42. % Binding X after the call doesn't help.
Because var/1 inspects the instantiation state at call time, the predicate is not a relation — it is a one-directional function.
Programs that work in one direction but silently fail in another
The deeper issue: calling describe(X, integer) with an unbound X succeeds immediately with Type = unknown instead of waiting for X to be constrained. No error, no warning — just a silently wrong answer. These bugs are extraordinarily hard to trace in larger programs.
Replacing var/1 checks with constraints and clean data structures
The correct approach uses dif/2 and CLP(FD) to express relationships that hold regardless of instantiation order:
:- use_module(library(clpfd)).
% Correct: works in multiple modes
is_integer_val(X) :- integer(X).
is_integer_val(X) :- var(X), X in inf..sup. % Constrain, don't branch
% Better: use dif/2 for inequality constraints
not_zero(X) :- dif(X, 0).
Here is a comparison table showing which query modes each approach supports:
| Approach | ?- p(42, T) | ?- p(X, integer) | ?- p(X, T), X=42 |
|---|---|---|---|
| var/1 dispatch | ✅ Works | ❌ Wrong answer | ❌ Wrong answer |
| dif/2 + CLP(FD) | ✅ Works | ✅ Works | ✅ Works |
| Pattern matching only | ✅ Works | ✅ Works (unification) | ✅ Works |
Whenever you reach for var/1, ask: can I express this with unification, dif/2, or CLP(FD) constraints instead? In most cases, yes.
Mistake 4: Printing Answers Inside Predicates with format/2
It is tempting to write a solve/0 predicate that computes an answer and prints it. This feels complete — one call does everything. But it makes your code untestable, non-composable, and impossible to use as a true relation.
Why side-effecting output breaks composability and testability
% Broken: solve/0 with embedded format/2
:- use_module(library(clpfd)).
solve :-
X + Y #= 10,
X #> 0, Y #> 0,
label([X, Y]),
format("Solution: X=~w, Y=~w~n", [X, Y]).
You cannot:
- Capture the solution as a Prolog term for further processing
- Write a unit test that checks
X = 3, Y = 7 - Reuse
solveinside another predicate that needs the values - Call it from a web handler that wants JSON instead of terminal output
The output goes to stdout and vanishes from the logic layer entirely.
Letting the toplevel do the printing: pure relational style
% Correct: pure solution/2 relation
:- use_module(library(clpfd)).
solution(X, Y) :-
X + Y #= 10,
X #> 0, Y #> 0,
label([X, Y]).
Now the toplevel prints all solutions automatically when you query ?- solution(X, Y). — and you can also write tests:
% Three-line plunit test for the pure predicate
:- use_module(library(plunit)).
:- begin_tests(solution_tests).
test(basic) :- solution(3, 7).
test(symmetric) :- solution(7, 3).
test(no_zero) :- \+ solution(0, 10).
:- end_tests(solution_tests).
Run with ?- run_tests(solution_tests). — three passing tests, zero terminal clutter in your logic.
When you need formatting: using format_//2 DCG nonterminal
If you genuinely need formatted output (generating a report, serialising data), keep it pure using the format_//2 DCG nonterminal from library(dcg/basics) or by building a codes/atom list relationally:
:- use_module(library(dcg/basics)).
% Describe the format as a relation over a list of character codes
solution_text(X, Y) -->
"Solution: X=", integer(X), ", Y=", integer(Y), "\n".
This keeps the formatted result as a Prolog term you can inspect, test, and pass around.
Mistake 5: Reaching for Low-Level Arithmetic Instead of CLP(FD)
is/2 evaluates arithmetic expressions and unifies the result. It has served Prolog programmers for decades. The catch: both sides must be fully ground at call time. This eliminates entire categories of queries that should logically work.
The is/2 operator and its ground-term limitation
% Broken: digit sum puzzle with is/2
digit_sum_broken(A, B, C, Sum) :-
Sum is A + B + C.
?- digit_sum_broken(1, 2, 3, S).
S = 6. % Works ground-first.
?- digit_sum_broken(A, 2, 3, 6).
ERROR: Arguments are not sufficiently instantiated
% Cannot solve for A even though it's uniquely determined!
Why CLP(FD) constraints work in all query directions
CLP(FD) (Constraint Logic Programming over Finite Domains) replaces is/2 with constraint operators (#=, #>, #<, etc.) that work bidirectionally. Instead of evaluating immediately, they post constraints and propagate:
% Correct: digit sum puzzle with CLP(FD)
:- use_module(library(clpfd)).
digit_sum(A, B, C, Sum) :-
A in 0..9, B in 0..9, C in 0..9,
Sum #= A + B + C.
?- digit_sum(1, 2, 3, S).
S = 6.
?- digit_sum(A, 2, 3, 6).
A = 1.
?- digit_sum(A, B, C, 6), label([A,B,C]).
A = 0, B = 0, C = 6 ;
A = 0, B = 1, C = 5 ;
A = 0, B = 2, C = 4 ;
% ... all 28 solutions generated automatically
The label/1 call triggers the labelling strategy — it searches the constrained domains for concrete values. Without label/1, you get constraint residuals; with it, you get full enumeration.
When is/2 is still appropriate
is/2 is fine when you are computing with floats (CLP(FD) is integers only), when performance is critical and all terms are guaranteed ground, or when interfacing with external numeric libraries. For puzzle-solving, combinatorics, or any predicate meant to work in multiple directions, CLP(FD) is the right tool.
| Dimension | is/2 | CLP(FD) |
|---|---|---|
| Directionality | Ground-only (left side) | All modes |
| Constraint propagation | None | Full arc consistency |
| Generates solutions | No | Yes (with label/1) |
| Float support | Yes | No (integers only) |
| SWI-Prolog availability | Built-in | library(clpfd) (bundled) |
| Testability | Hard (one direction) | Easy (multiple modes) |
Common Issues and Fixes When Refactoring Away from These Patterns
Error: Unknown procedure: dif/2
Cause: dif/2 is in library(dif), which is not auto-loaded in all SWI-Prolog configurations.
Fix: Add this directive to the top of your file:
:- use_module(library(dif)).
Or load it interactively:
?- use_module(library(dif)).
true.
?- dif(X, 3), X = 4.
X = 4. % Now works.
Error: Unknown procedure: if_/3
Cause: library(reif) is a third-party pack not bundled with SWI-Prolog.
Fix: Install it once via the pack system:
?- pack_install(reif).
Then add to your source file:
:- use_module(library(reif)).
In scryer-prolog, use :- use_module(library(reif)). directly — it is built in.
Error: CLP(FD) label/1 hangs indefinitely
Cause: You called label/1 on variables without restricting their domains first. CLP(FD) needs finite domains to enumerate.
Symptom and fix:
% Hangs: X has no finite domain
?- X #> 0, label([X]).
% Never returns.
% Fix: add explicit domain bounds before labelling
?- X #> 0, X #< 100, label([X]).
X = 1 ;
X = 2 ;
% ...
Always pair label/1 with domain constraints like X in 1..100 or X #< SomeUpperBound.
Error: Tests still failing after removing assertz
Cause: You removed the assertz calls but the dynamic predicate declaration (:- dynamic counter/1.) and its initial facts are still loaded from a previous session or a leftover .pl file.
Fix: Check for residual dynamic facts:
?- predicate_property(counter(_), dynamic).
true. % It still exists.
?- abolish(counter/1). % Remove entirely if no longer needed.
Or restart swipl and reload only the refactored file. Use make/0 after edits to reload cleanly.
FAQ: Prolog Anti-Patterns Beginners Ask About
Q: Is cut ever acceptable in production Prolog code?
Yes — in two narrow situations. First, green cuts that do not change the set of solutions but only remove redundant choice points for performance (e.g., after a deterministic type check where you know no other clause can match). Second, in explicitly procedural, non-backtracked predicates like main/0 entry points. The test: remove the cut and verify the predicate still gives correct answers. If removing it changes the answers, it is a red cut and a liability. For anything relational, replace it with if_/3, dif/2, or CLP constraints.
Q: Can I use assertz/1 for caching (memoization) safely?
Manual assertz-based memoization is fragile and the wrong tool. SWI-Prolog provides a purpose-built, sound alternative: the table/1 directive (tabling). Declare ':- table fib/2.' above your predicate and SWI-Prolog automatically memoizes results in a per-call trie that is thread-safe and correctly handles the Prolog execution model, including tabling of cyclic terms. Use ?- abolish_all_tables. to clear the cache. This is safer, faster, and requires zero manual retract logic.
Q: How do I know if my predicate is truly relational?
Run it in at least three query modes: ground-in (all arguments bound), partially-instantiated (some arguments unbound), and reverse (output argument as input). If any mode produces an error or a wrong answer while a correct answer logically exists, the predicate is not fully relational. A quick checklist: does it use is/2 with unbound variables? Does it use var/1 or nonvar/1 as guards? Does it use !/0 in a way that changes answers? If yes to any of these, it needs refactoring toward the constraint-based alternatives shown in this guide.