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 swipl from 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 assertz facts 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 solve inside 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.

Recommended Tools