The program has to choose
Up to now every statement you wrote ran the same way on every scan. Horn := DoorOpen AND NOT MaintKey; is evaluated top to bottom, every 10 ms, and the answer changes only because the inputs change. That carries you a surprisingly long way — most interlocks are one boolean expression — but sooner or later a machine needs to do different things in different situations. A filling valve opens while the tank is low and closes when it is full. A drive runs at the recipe speed in automatic, at a crawl in jog, and at zero when it is faulted. That is what IF is for.
The shape is the same in every IEC 61131-3 environment, from CODESYS and TwinCAT 3 to Siemens SCL in TIA Portal and Structured Text routines in Studio 5000:
IF condition THEN
(* statements that run when the condition is TRUE *)
ELSE
(* statements that run when it is FALSE *)
END_IF;
The condition is any expression that produces a BOOL: a single input, a comparison like Level < 90.0, or a full interlock built from AND, OR and NOT as in chapter 4. THEN opens the branch, END_IF; closes the whole statement, and the semicolon after END_IF is required. Inside the branches you can put as many statements as you like, including other IFs.
Here is a filling valve on a 2,000-litre buffer tank. The level transmitter reports percent; the valve should be open below 90 % and closed at or above it.
PROGRAM Main
VAR
Level : REAL := 40.0; (* input: tank level in %, 0..100 *)
FillValve : BOOL; (* output: solenoid on the inlet valve *)
END_VAR
IF Level < 90.0 THEN
FillValve := TRUE;
ELSE
FillValve := FALSE;
END_IF;
END_PROGRAMType a new value into Level and watch the valve on the next scan. Try 89.9, 90 and 95. The IF is re-evaluated on every scan, exactly like the one-line assignments in the previous chapters. Nothing about an IF makes it "happen once": there is no event, no waiting. Each scan the controller asks the question again and takes one branch.
(A real filling valve switched at a single threshold would chatter open and shut as the surface ripples around 90 %. The cure is hysteresis, and it is the main subject of chapter 6. For now, a single threshold keeps the IF easy to see.)
ELSIF: the first true branch wins
When there are more than two cases, chain them with ELSIF. The controller tests the conditions from the top. The first one that is TRUE runs its branch, and every condition below it is skipped — not evaluated, not run. If none is TRUE, the ELSE branch runs, if there is one.
That makes the order of an ELSIF chain a priority list. Write the most important condition first. On a conveyor drive the priority is nearly always the same: a fault stops it, a jog request overrides normal running, and only then does the automatic speed apply.
PROGRAM Main
VAR
Fault : BOOL; (* input: drive or motor fault *)
Jog : BOOL; (* input: jog push-button, held *)
Run : BOOL; (* input: automatic run request *)
SpeedSetpoint : REAL := 75.0; (* input: recipe speed in % *)
SpeedCmd : REAL; (* output: speed reference for the VFD in % *)
END_VAR
IF Fault THEN
SpeedCmd := 0.0;
ELSIF Jog THEN
SpeedCmd := 10.0; (* crawl speed for threading the belt *)
ELSIF Run THEN
SpeedCmd := SpeedSetpoint;
ELSE
SpeedCmd := 0.0;
END_IF;
END_PROGRAMSwitch on Run, then Jog, then Fault, in any combination. Whatever else is on, Fault forces zero, because its branch is tested first. Jog beats Run because it sits above it. Read the chain aloud from the top and you have the specification: "if faulted, zero; otherwise if jogging, ten percent; otherwise if running, the setpoint; otherwise zero."
One more property follows from "first true wins": the branches of a chain are mutually exclusive. Exactly one of them runs per scan, or none if there is no ELSE. Two separate IF statements are different — both can run in the same scan, and the second one has the last word. Keep that difference in mind; it comes back below.
Nesting, and when to flatten it
An IF can contain another IF. Nesting reads naturally when the outer condition is a mode and the inner conditions only make sense inside it:
IF AutoMode THEN
IF Level < 90.0 THEN
FillValve := TRUE;
ELSE
FillValve := FALSE;
END_IF;
ELSE
FillValve := ManualOpen;
END_IF;
Each IF needs its own END_IF;, and indentation is the only thing that tells a human which ELSE belongs to which IF. The compiler matches them by structure, not by indentation, so a mis-indented block compiles fine and misleads the next reader. Indent every level by the same amount — four spaces is common — and let the editor do it for you.
Two levels are fine. Three are a warning sign. By four you are writing a maze: the condition for the innermost line is the AND of everything above it, and nobody can see it at a glance. Most deep nests flatten into a short ELSIF chain with combined conditions:
IF NOT AutoMode THEN
FillValve := ManualOpen;
ELSIF Level < 90.0 THEN
FillValve := TRUE;
ELSE
FillValve := FALSE;
END_IF;
Same behaviour, one level, and each line reads as a full rule. When a nest grows because of machine modes — off, manual, automatic, maintenance — the right tool is usually CASE, which you will meet in chapter 7.
The missing-ELSE trap
Here is the most common IF bug in PLC code, and it compiles without a warning. Someone writes a high-level alarm like this:
IF Level > 80.0 THEN
HighAlarm := TRUE;
END_IF;
It looks complete. Raise the level above 80 % and the alarm comes on. Now let the level fall back to 50 % — and the alarm stays on. Forever, or until the PLC restarts.
The reason is the scan. When Level > 80.0 is FALSE, the IF does nothing at all. No statement writes HighAlarm, so it keeps the value it had at the end of the previous scan: TRUE. A variable in a PLC is memory. It holds its value until something writes a new one. An IF without an ELSE is, in effect, a latch: it can set the output, and nothing ever clears it.
Try it live. The program computes the same alarm twice — once with the missing ELSE, once correctly.
PROGRAM Main
VAR
Level : REAL := 50.0; (* input: tank level in % *)
HighLatched : BOOL; (* the bug: IF with no ELSE *)
HighAlarm : BOOL; (* correct: IF with ELSE *)
END_VAR
IF Level > 80.0 THEN
HighLatched := TRUE;
END_IF;
IF Level > 80.0 THEN
HighAlarm := TRUE;
ELSE
HighAlarm := FALSE;
END_IF;
END_PROGRAMSet Level to 85: both turn on. Set it back to 50: HighAlarm clears, HighLatched stays TRUE. In a plant this shows up as a pump that never stops, a lamp that will not go out, or a valve that "sticks" in software. The engineer on site sees an output that is on when every condition in the IF is false, and the IF looks perfectly innocent.
The trap is easy to miss in a review because the logic you see is right. What is wrong is the logic you do not see: the path where the condition is false and nothing happens.
That comparison with ladder is worth keeping. A normal coil in LD is an assignment: it writes the rung result every scan. A Set coil (-(S)-) is an IF without ELSE. If you came from ladder, an IF with no ELSE should look to you like a Set coil, because that is what it is.
Default first, then override
There are two clean ways to make sure an output is written on every scan. The first is the full IF/ELSE you have already seen: every branch writes every output. The second is to write a default at the top, unconditionally, and let later IFs override it:
PROGRAM Main
VAR
LowAir : BOOL; (* input: air pressure switch below 5 bar *)
DoorOpen : BOOL; (* input: panel door switch *)
FilterDirty : BOOL; (* input: differential pressure switch on the filter *)
Beacon : BOOL; (* output: amber warning beacon *)
END_VAR
Beacon := FALSE; (* default: no warning *)
IF LowAir THEN
Beacon := TRUE;
END_IF;
IF DoorOpen THEN
Beacon := TRUE;
END_IF;
IF FilterDirty THEN
Beacon := TRUE;
END_IF;
END_PROGRAMEach IF on its own has no ELSE, and yet nothing latches. The first line resets Beacon at the start of every scan, so the output is TRUE only if some IF set it in this scan. Remember from chapter 1 that only the final value reaches the terminal, so the brief FALSE at the top of the scan never causes a flicker.
Both patterns are correct. They suit different situations.
When you do not need an IF at all
Look again at the corrected high-level alarm:
IF Level > 80.0 THEN
HighAlarm := TRUE;
ELSE
HighAlarm := FALSE;
END_IF;
Five lines to say something the comparison already says. Level > 80.0 is a BOOL, so you can assign it directly:
HighAlarm := Level > 80.0;
This is not just shorter. A plain assignment cannot latch, because it writes the output on every scan by construction. There is no ELSE to forget. Likewise, the beacon above is simply Beacon := LowAir OR DoorOpen OR FilterDirty;. An IF whose only job is to write TRUE in one branch and FALSE in the other should almost always be an assignment. Plenty of in-house coding standards make that a review rule.
Keep IF for real choices: when a branch does several things, when the outputs are not booleans (speeds, setpoints, step numbers), or when you need to protect a calculation from running with bad data. That last case deserves a section of its own.
Guarding a calculation
Division is the classic calculation that must not run with bad data. Divide by zero on a controller and you do not get a polite error message. Depending on the platform you get a CPU exception that stops the program, a minor fault and a nonsense result, or a REAL that becomes infinite or NaN and quietly poisons every value calculated from it. The engine on this site treats it as a runtime fault, which is the honest choice: it makes the bug impossible to miss.
The guard is an IF around the calculation, with an ELSE that gives the result a defined, harmless value:
PROGRAM Main
VAR
TotalLitres : REAL := 1250.0; (* input: litres dispensed this shift *)
Batches : INT := 0; (* input: batches completed this shift *)
AvgLitres : REAL; (* output: average litres per batch *)
AvgValid : BOOL; (* output: TRUE when AvgLitres means something *)
END_VAR
IF Batches > 0 THEN
AvgLitres := TotalLitres / INT_TO_REAL(Batches);
AvgValid := TRUE;
ELSE
AvgLitres := 0.0;
AvgValid := FALSE;
END_IF;
END_PROGRAMAt the start of a shift Batches is 0, and the program runs happily. Type 5 into Batches and the average appears. Two details are deliberate. The guard tests Batches > 0, not Batches <> 0, because a negative batch count is just as meaningless as zero. And the ELSE writes both outputs, with AvgValid telling the HMI and the rest of the program that 0.0 is a placeholder, not a measurement. A guard that just skips the division leaves the old average sitting in AvgLitres — the missing-ELSE trap in a new costume.
The same pattern protects square roots of negative numbers, array indexes that might be out of range (chapter 13), and any scaling that divides by a span an operator can type in.
One output, one owner
Everything in this chapter assumes that the output you are reasoning about is written in one place. In real projects that assumption breaks all the time. The fill valve is opened by the automatic sequence in one routine, by a manual-mode IF in another, and closed by a safety IF somewhere near the bottom of a third. Each IF is fine on its own. Together they produce behaviour that depends on routine execution order, and the engineer reading any one of them has no idea the others exist.
(* Routine A — automatic filling *)
IF AutoMode AND Level < 90.0 THEN
FillValve := TRUE;
ELSE
FillValve := FALSE;
END_IF;
(* Routine B, 400 lines later — manual mode *)
IF ManualOpen THEN
FillValve := TRUE; (* silently overrides routine A *)
END_IF;
Here routine B has the last word whenever ManualOpen is on, and it also contains a latent latch. The fix is structural: give FillValve one owner. Work out the requests wherever they belong (AutoFillRequest, ManualFillRequest, FillPermissive), then write the output in exactly one place, with one ELSIF chain or one expression that a reviewer can read in full. Most cross-reference tools — TIA Portal's, Studio 5000's, CODESYS's — will list every write to a tag. If an output has more than one write, ask why. Chapter 21 builds a whole program structure on this rule.
From choices to boundaries
You can now make a program choose: IF for two paths, ELSIF for a priority list, nesting where a mode genuinely contains its rules, and guards around calculations that must not run on bad data. You also know the trap that catches everyone once — the IF with no ELSE that turns an output into a latch — and the two habits that prevent it: write every output on every path, or write a default first.
Most of the conditions you wrote here were comparisons against a number: Level < 90.0, Level >= 95.0, Strokes > 0. Those look trivial and are not. Which side of the setpoint does "exactly 90 %" belong to? What happens when a noisy level sits right on the line? Why does = on a REAL almost never mean what you think? The next chapter is about comparisons and thresholds, and about making them behave at the boundary.