◆ New — Strategy Validator, Code Fixer & Prop Compliance Checker are live. Try one free →
Home / Learn / How to Debug Pine Script: plotchar, Labels, log.info, and the Pine Logs Pane
Pine Script

How to Debug Pine Script: plotchar, Labels, log.info, and the Pine Logs Pane

Pine Script has no step-through debugger, so debugging means making values visible: plot() and plotchar() into the Data Window, label.new() and tables on the chart, and log.info() into the Pine Logs pane. This guide builds a systematic workflow from those tools — and shows how to recognise when the 'bug' is not your logic at all but repainting or series/simple type confusion. Educational material only, not financial advice: these techniques verify that code does what you intended, which is a separate question from whether any strategy makes money, and trading carries a real risk of loss.

How do I print or debug variable values in Pine Script?

There is no debugger, no breakpoints, and no print() in Pine Script — you cannot pause execution and inspect state. Instead you make values visible through three output channels: plots (plot(), plotchar()) that surface numbers and conditions in the Data Window for any bar you hover; drawings (label.new(), tables) that pin formatted text to specific bars on the chart; and logs (log.info(), log.warning(), log.error()) that print timestamped messages to the Pine Logs pane. Each channel answers a different question, and an effective workflow uses all three.

The reason a deliberate workflow matters is Pine's execution model: your script runs once per historical bar, in order, and then repeatedly on the realtime bar as ticks arrive. A variable does not have a value — it has a value on every bar, and most bugs are about the bar you did not look at. Plots are the right tool for "what was this value across all of history" (hover any bar, read the Data Window). Labels are the right tool for "show me the exact state at the moment this condition fired." Logs are the right tool for "give me a sequential trace I can read like a console."

One discipline up front saves the most time: when something looks wrong, resist the urge to change logic immediately. First instrument the script — expose the inputs to the failing expression, one plot or log per sub-expression — and locate the first bar where an intermediate value diverges from your expectation. Almost every Pine bug reduces to one of: the value is na when you assumed it was not, the condition fires on a different bar than you thought, or the value differs between historical and realtime execution. The sections below give you the tooling for each, in that order.

The Data Window is your watch panel: plot() and plotchar()

The fastest inspection tool costs one line per variable. plot() any intermediate value with display = display.data_window and it appears in the Data Window (the panel that updates as you hover bars) without drawing anything on the chart itself — a clean substitute for watch expressions:

//@version=6
// Educational only — validate before trading; not financial advice.
indicator("Debug values", overlay = true)

fast   = ta.ema(close, 12)
slow   = ta.ema(close, 26)
spread = fast - slow

// Values readable in the Data Window on hover, chart stays clean.
plot(spread, "spread", display = display.data_window)
plot(fast,   "fast",   display = display.data_window)

// Boolean condition as a chart marker, confirmed bars only.
sig = ta.crossover(fast, slow)
plotchar(sig and barstate.isconfirmed, "sig", "•", location.top, color.teal)

Hover any historical bar and you can read spread and fast exactly as the script computed them there — which is how you find the bar where your assumption first breaks. For booleans, plotchar() puts a character on qualifying bars; gating with barstate.isconfirmed keeps the marker honest on the realtime bar, so you never chase a "signal" that only existed intrabar. bgcolor(cond ? color.new(color.yellow, 85) : na) is the same idea for conditions that describe a state rather than an event.

Two practical notes. First, plot arguments must be available at global scope — you cannot call plot() inside an if block, so debug-plot the condition itself, not from within it. Second, na renders as an empty Data Window cell, which makes this channel the quickest way to spot the single most common Pine bug: an indicator that is na for its whole warm-up period (a 200-bar average is na for the first 199 bars) silently making every comparison built on it false. If a strategy "never enters," hover bar 50 and look for the blanks.

Labels for point-in-time state: str.tostring and str.format

When the question is "what exactly was the state when this fired?", a label pinned to the signal bar beats hovering. Build the text with str.tostring() for single values or str.format() for several at once, and gate creation on the confirmed bar so the snapshot describes a closed bar, not a still-moving one:

//@version=6
// Educational only — validate before trading; not financial advice.
indicator("Debug label", overlay = true, max_labels_count = 500)

rsi = ta.rsi(close, 14)
sig = ta.crossunder(rsi, 30)

if sig and barstate.isconfirmed
    label.new(bar_index, low,
         str.format("rsi={0, number, #.##}\nclose={1}", rsi,
             str.tostring(close, format.mintick)),
         style = label.style_label_up, color = color.gray, textcolor = color.white)

str.format() uses numbered placeholders with optional formatting — {0, number, #.##} rounds to two decimals — and \n inside the string gives multi-line labels, so one label can carry a whole state dump: the trigger value, the threshold, the filter states that allowed it through. max_labels_count = 500 raises the retention cap to its maximum; by default only roughly the last 50 labels survive, so on longer histories your earliest debug labels vanish — that disappearance is a platform object limit, not a bug in your script.

For state you want continuously visible rather than per-event — current position of every filter, the last computed levels — a small table updated inside barstate.islast acts as a live dashboard in the chart corner without consuming per-bar objects.

A workflow habit worth stealing from code review: when a conditional entry misbehaves, label both branches for a while — one label when the signal passes, another (different colour) when it is blocked, with the blocking filter named in the text. Most "my strategy skipped an obvious entry" mysteries end thirty seconds after the blocked-branch label tells you which condition said no.

log.info() and the Pine Logs pane: the closest thing to a console

Pine's logging functions — log.info(), log.warning(), log.error() — print timestamped messages to the Pine Logs pane, which you open from the Pine Editor's menu. Logs work in indicators and strategies you have the source open for, and they accept str.format()-style placeholders directly, so a trace line is one call:

//@version=6
// Educational only — validate before trading; not financial advice.
indicator("Pine Logs demo", overlay = true)

atr      = ta.atr(14)
breakout = ta.crossover(close, ta.highest(high, 20)[1])

if breakout and barstate.isconfirmed
    log.info("breakout: close={0} atr={1} bar={2}", close, atr, bar_index)

This reads like a console: a sequential, timestamped record of every time the condition fired, with the values that accompanied it. The three severity levels are colour-coded and filterable in the pane, so a sensible convention is log.info for trace output, log.warning for "this state should be rare," and log.error for "this should be impossible" — turning your assumptions into visible assertions.

The discipline logging demands is volume control. The pane keeps only a rolling window of the most recent messages, so an unconditional log.info() on every bar of a deep intraday history floods the buffer and scrolls away the bars you actually care about; on the realtime bar, an ungated call fires on every tick. Always log under a condition, and gate realtime logging with barstate.isconfirmed unless tick-by-tick behaviour is precisely what you are investigating — one log call per confirmed event is the signal-to-noise sweet spot.

Where logs beat plots and labels outright: values that are not numbers on a price scale (strings, array sizes via str.tostring(array.size(a)), branch names), and execution-order questions — which condition fired first on a bar — where a timestamped sequence is exactly the evidence you need.

When the 'bug' is actually repainting

A special class of bug report: "the signal was there yesterday and now it's gone," or "the backtest looks brilliant but live behaviour doesn't match." Before you dig through your logic, suspect repainting — the script computing different values on the realtime bar than it later shows for the same bar as history. Your logic can be entirely bug-free and still repaint.

The diagnostic is a divergence test. Instrument the suspect value with a plot or log, watch it live on the realtime bar, then reload the chart and compare the same bar as history — if they disagree, you have repainting, not a logic error. TradingView's Bar Replay is the systematic version: step through history bar by bar and check whether signals appear and stay, or appear and vanish. Any signal built on a value that moves intrabar (which includes close itself, until the bar confirms) will flicker; that is exactly what barstate.isconfirmed gating exists to contain.

The worst repainting is self-inflicted through request.security():

// ❌ WRONG — lookahead_on with no offset fetches the day's close
// before it exists. Historical bars look prophetic; the realtime
// bar can't be, so the 'bug report' is really a repaint.
htf = request.security(syminfo.tickerid, "D", close, lookahead = barmerge.lookahead_on)
//@version=6
// Educational only — validate before trading; not financial advice.
indicator("Stable HTF value", overlay = true)
// ✅ Confirmed previous daily close — identical on historical and
// realtime bars, so what you test is what you get.
htf = request.security(syminfo.tickerid, "D", close[1], lookahead = barmerge.lookahead_off)
plot(htf, "prev daily close")

The [1] offset plus barmerge.lookahead_off requests only confirmed higher-timeframe data, which cannot change after the fact. If your debugging session ends with "the values differ between live and reload," the fix is never more debugging output — it is restructuring the calculation so it only consumes confirmed data. This is precisely the class of defect ForexCodes' audit tooling screens for, because it produces backtests that are honest-looking and wrong.

When the 'bug' is the type system: series vs simple

The other great impostor is a compile-time error that reads like gibberish until you know Pine's type qualifiers: "Cannot call 'ta.ema' with argument 'length'. An argument of 'series int' type was used but a 'simple int' is expected." Nothing is wrong with your algorithm — the value you passed is more dynamic than the function tolerates.

Every Pine value carries a qualifier describing when it is knowable: const (at compile time), input (at the settings dialog), simple (at bar zero, then fixed), and series (may change every bar). Functions declare the weakest qualifier they accept, and the rules only allow passing up that ladder, never down. Some built-ins genuinely need stability: ta.ema() requires a simple int length because its recursive weighting is fixed at initialization, while ta.sma() happily accepts a series int and can vary bar to bar. The moment you compute a length from anything bar-dependent — int(atrValue), a ternary on price — it becomes series, and ta.ema refuses it.

Debugging this class is about locating where a value became series, and the compiler's error message names the argument — read it literally, then trace that variable's construction upward. Common escalation points: any expression involving price or an indicator, ternaries with series conditions, and values returned from request.security(). Fixes are structural, not cosmetic: keep lengths derived from input.int() untouched by series math, or switch to a function that accepts series arguments.

The related trap is silent rather than loud: na propagation. Almost any arithmetic on na yields na, comparisons involving na are effectively false, and a single unwarmed indicator can disable an entire entry condition without any error at all. The Data Window blanks from earlier are your detector; nz() or explicit na() checks at the source are the repair. Compiler errors and silent nas bracket Pine debugging: one is noisy and misread, the other is quiet and missed — and neither is fixed by tweaking strategy logic. Educational only; verify behaviour before ever risking capital.

Key takeaways

Educational only — not financial advice. Trading involves substantial risk of loss.

Catch the bug that compiles.Run auditGet Pro