Learn PLCs free
Programming Guides10 min read1,805 words

12 Common PLC Programming Mistakes That Cost Plants Millions (and How to Avoid Them)

The 12 PLC programming mistakes that show up in every plant audit — from output coil reads and missing fault handling to hard-coded magic numbers. Each with the fix and a code example.

PPI
PLC Programming IO Editorial Team
Sourced guidance with documented review and correction standards

Why this post exists

Every plant audit, every controls-engineering retrospective, every "why did the line go down at 2 AM" investigation surfaces the same handful of programming mistakes over and over. They're not exotic. They're not platform-specific. They show up on Allen-Bradley, Siemens, Mitsubishi, Schneider and OpenPLC alike.

This post catalogues the 12 mistakes that come up most consistently in code reviews and post-mortems — the ones that cause production downtime, expensive engineering rework, and (in the worst cases) safety incidents. Each entry includes a worked code example showing the bug and the fix.

If you're reviewing code (yours or someone else's) before commissioning, this is your checklist.

Mistake 1: Reading output coils inside the same scan they were set

The bug: Output coils only update at the end of the scan cycle (phase 3). Logic that reads M_FWD mid-scan sees the previous scan's value, not what was just written.

Rung 12 — set M_FWD when start pressed
──┤ Start_PB ├──┤ E_Stop_OK ├──────( M_FWD )──

Rung 13 — light the "running" lamp when motor is on
──┤ M_FWD ├──────────────────────( Lamp_Run )──   // BUG

In the same scan that pressed Start_PB, M_FWD reads as still FALSE on rung 13 — the OTE coil only updates the physical output and the next-scan readback at end-of-scan. The lamp lights one scan late, which is invisible in normal operation but produces race conditions in tight interlock chains.

The fix: Use input states or internal flags that update synchronously. Either a separate "Motor command" internal bit set on rung 12 and read on rung 13, or read the actual feedback input from the contactor auxiliary contact:

──┤ M_FWD_AUX_FB ├───────( Lamp_Run )──   // FIX — reads the field input

Mistake 2: Hard-coded magic numbers everywhere

The bug: Recipe values, alarm setpoints, timer presets, scaling constants embedded directly in rung logic.

TON_001(IN := Mixing, PT := T#180s);    // why 180? nobody remembers
IF Tank_Level > 9532 THEN ...           // 9532 of what?
Pump_SP := Setpoint * 4096 / 100;       // magic factor 4096

Every time someone needs to change the mix time, the alarm threshold, or recalibrate the loop, they have to dig into the code, re-download to the PLC, and retest. Worse, the same number often appears in multiple places, so a "change" misses one and the system misbehaves.

The fix: Declare every parameter as a named retentive tag in a recipe DB or parameter table. Change values from the HMI without touching the code.

PROGRAM main
VAR
    BatchMixTimeSec : INT := 180;     // editable from HMI
    TankHighLimit   : REAL := 95.0;   // %, editable
    AnalogScaleFactor : REAL := 0.0244;  // 4096/100, editable
END_VAR

Mistake 3: No fault handling on field signals

The bug: The code assumes every transmitter responds, every motor starts, every comm link stays up.

HeaterPct := PID(SP := Setpoint, PV := TankTemp);   // what if TankTemp = NaN?
Pump_Cmd := TankLevel > Start_SP;                    // what if level Tx fails?

When a transmitter loses signal, returns BAD QUALITY, or simply reads zero because of a broken wire, the code keeps running with garbage data. Heaters can stay on with no feedback. Pumps can run dry. Levels can overflow.

The fix: Every analog signal needs a quality check. Every comm link needs a watchdog. Critical actuators need a fall-back state on bad input.

IF NOT TankTempStatus.Good OR TankTemp < 0.0 OR TankTemp > 200.0 THEN
    HeaterPct := 0;
    Alarm_TempBad := TRUE;
ELSE
    HeaterPct := PID(SP := Setpoint, PV := TankTemp);
END_IF;

Mistake 4: Spaghetti rungs in one massive routine

The bug: 800 rungs in MainProgram. No sub-routines. No function blocks. State machine logic, interlocks, scaling, alarms, recipe handling all jumbled together.

The result: the program is impossible to read, impossible to test in isolation, and changes ripple unpredictably across unrelated equipment.

The fix: One routine per equipment item. Pumps in PumpControl. Conveyors in ConveyorControl. Recipes in RecipeManager. Use AOIs (Studio 5000) or function blocks (TIA Portal, CODESYS) to encapsulate reusable logic.

PROGRAM main
    PumpControl_P101(Run_Cmd := Recipe.Pump1Run, Status => P101_Stat);
    PumpControl_P102(Run_Cmd := Recipe.Pump2Run, Status => P102_Stat);
    ConveyorControl_C201(Cmd := MainSeq.Conv1Cmd, Status => C201_Stat);
    Mixer_M301(Cmd := MainSeq.MixCmd, Status => M301_Stat);
END_PROGRAM

Mistake 5: Generic, undocumented tag names

The bug: Motor1, Timer7, Bool_3, M01.0, DB1.DBX5.2. Nobody — including the original author six months later — can read the code without cross-referencing it to a printout.

The fix: Tag names are documentation. Use descriptive names that convey intent and direction:

  • E_Stop_OK (TRUE means E-stop NOT pressed) instead of E_Stop
  • Conveyor1_RunCmd instead of M01.0
  • Tank_FillTimer instead of T7
  • BatchActive instead of M_3
  • RecipeStep_Current instead of Word_5

The cost is zero. The benefit is enormous. This is the single highest-ROI habit you can adopt.

Mistake 6: Latched outputs without unlatch on every fault path

The bug: A SET-RESET pattern (OTL/OTU in Allen-Bradley, S/R in IEC) drives a contactor. The Set rung includes Start + permissives. The Reset rung includes Stop and one or two faults — but misses E-stop, overload, and door-open.

Rung 1: Set
──┤ Start ├──┤ NoFaults ├──────────────────────(L Pump_Run)──

Rung 2: Reset (incomplete)
──┤ Stop ├─────────────────────────────────────(U Pump_Run)──

When E-stop fires, Start goes false but the latched coil stays set. The contactor is still energised. Operator hits Reset and the pump tries to start before they've cleared the cause.

The fix: Reset on every non-permissive condition, not just Stop:

Rung 2: Reset (correct)
──┬──┤ Stop ├──────────────────────────────────(U Pump_Run)──
  ├──┤ EStop_Pressed ├─────────────────────────(U Pump_Run)──
  ├──┤ OL_Tripped ├────────────────────────────(U Pump_Run)──
  └──┤ DoorOpen ├──────────────────────────────(U Pump_Run)──

Better: avoid OTL/OTU entirely and use seal-in patterns where the fault path is structurally part of the rung.

Mistake 7: Misunderstanding the scan cycle

The bug: Logic that depends on bits changing within a single scan, but reads inputs that update only at scan start.

This is the #1 source of "but it worked in simulation" bugs. Beginners write code that flips a flag and then immediately reads it back, expecting the new value. The PLC happily executes both rungs in the same scan, with the read seeing the old value.

The fix: Internalise the four-phase scan cycle (read inputs → execute program → write outputs → housekeeping). For tight timing requirements, use edge detection (R_TRIG / F_TRIG) and one-shots rather than relying on multi-rung order. See the PLC Scan Cycle deep-dive for the full mental model.

Mistake 8: No commenting or documentation

The bug: Every rung has a tag name like RunFlag1 and zero comments. The reason a particular interlock exists is locked in the original author's head.

The fix: Three levels of documentation, all in-code:

  1. Per-routine header comment — what this routine controls, what it depends on
  2. Per-rung comment — why this rung exists, especially for interlocks and edge cases
  3. Per-tag description — what the tag means in plant terms, including units
(* Rung 14 — anti-coast interlock for E-stop reset
   Per HAZOP item 2.3: pump must be confirmed stopped (sense via aux contact)
   for at least 2 seconds before re-arming after E-stop. *)

Mistake 9: No version control, no change history

The bug: The PLC contains the only copy of the code. Backups are old. Multiple engineers edit live without communication. Nobody knows what changed last Tuesday.

The fix:

  • Every project lives in Git (or equivalent) with one commit per logical change.
  • Every PLC has an offline backup of the most recent compiled application, dated and stored in a known location.
  • Every change goes through a Management of Change (MOC) form that records who, what, why, when, with peer review.
  • Studio 5000 export-to-L5X and TIA Portal SCL export both produce text-friendly diffable files for code review.

Mistake 10: Reusing one timer for multiple jobs

The bug: Timer T1 is used in rung 5 to time a 5-second debounce. The same T1 is referenced again in rung 35 to time a 30-second cleanup. Whichever rung sees its IN go true last wins. Race conditions galore.

The fix: Each timer instance is its own dedicated tag with one purpose. Use AOIs or function blocks to make this naturally enforced.

VAR
    T_StartDebounce : TON;       // dedicated, only used for start debounce
    T_CleanupCycle  : TON;       // dedicated, only used for cleanup
END_VAR

Mistake 11: Skipping testing with simulation

The bug: "We'll test it on real hardware during commissioning." Then commissioning is a series of fire drills as bugs surface that simulation would have caught in five minutes.

The fix: Every PLC vendor offers a simulator (Siemens PLCSIM, Studio 5000 Logix Emulate, GX Works3 Simulator, CODESYS Control Win). Use it. Spend one day per week on a multi-week project running every sequence end-to-end with simulated I/O. Discover the bugs before commissioning, not during. See our free PLC simulators list for the full options.

Mistake 12: Not testing every interlock during commissioning

The bug: At SAT, the team verifies "the system runs" but doesn't deliberately trigger every cause-and-effect entry, every E-stop, every fault path. Months later, an interlock fires for the first time — and turns out to have been wired wrong since day one.

The fix: Use the cause-and-effect matrix as the test plan. Trigger every cause; verify every marked effect happens within the specified time. Document each test with witnesses signing off line-by-line. Repeat at every annual safety review.

SAT Test #L-007: LIT-101 HIHI alarm at 95%
Cause:  Force LIT-101 = 96.0
Expected effects:
  ✓ Banner alarm "Tank 1 HIHI" within 1 sec
  ✓ Pump P-101 trips within 2 sec
  ✓ Valve MV-101 closes within 3 sec
  ✓ Horn sounds within 1 sec
  ✓ SMS sent to maintenance within 30 sec
Witness: ___________  Date: ___________

A code-review checklist you can use today

Print this and tape it next to the workstation:

  • Tag names — every name conveys intent and direction (E_Stop_OK not E_Stop)
  • No magic numbers — every constant lives in a named retentive tag
  • Field signals — every analog input has a quality check before use
  • Watchdogs — every comm link, every external system has a watchdog
  • Routine separation — one routine per equipment, AOIs/FBs for reuse
  • Latched outputs — reset on every fault path, not just "Stop"
  • Timer instances — each timer used for exactly one purpose
  • Comments — every routine, every interlock rung, every non-obvious logic block
  • Version control — Git project, signed-off MOC for every change
  • Simulation tested — every sequence end-to-end before commissioning
  • C&E tested — every cause-and-effect entry verified at SAT
  • Scan cycle awareness — no bits read in the same scan they're set
#PLCProgramming#BestPractices#CommonMistakes#CodeReview#Troubleshooting
Share this article:

Related Articles