◆ New — Strategy Validator, Code Fixer & Prop Compliance Checker are live. Try one free →
Home / Learn / Pine Script Tables: How to Display Data on the Chart
Pine Script

Pine Script Tables: How to Display Data on the Chart

A code-first guide to Pine Script v6 tables: creating a table once with var table.new(), updating cells only on barstate.islast, building a live stats panel, and adding a performance panel to a strategy. Covers the classic mistake — recreating the table on every bar — and why it slows scripts down. Educational material only, not financial advice; a stats panel describes historical data and a backtest, never future performance, and trading carries a real risk of loss.

How do I create a table to display data in Pine Script?

You create a table once with table.new(), store it in a var variable so it persists across bars, and fill its cells with table.cell() inside an if barstate.islast block so the drawing work happens only on the final bar. A table is not anchored to bars like a plot — it is a fixed panel pinned to a corner of the chart that shows one current state, which is exactly why it only ever needs to be drawn once, with the latest values.

Here is the minimal correct pattern:

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

// Created once, persists across bars.
var table panel = table.new(position.top_right, 2, 2,
     bgcolor = color.new(color.black, 85))

// Series are computed on every bar, in global scope.
atrVal = ta.atr(14)

if barstate.islast
    table.cell(panel, 0, 0, "Symbol", text_color = color.white)
    table.cell(panel, 1, 0, syminfo.ticker, text_color = color.white)
    table.cell(panel, 0, 1, "ATR(14)", text_color = color.white)
    table.cell(panel, 1, 1, str.tostring(atrVal, format.mintick),
         text_color = color.white)

Three things to internalize. table.new(position, columns, rows) fixes the grid size and screen position (position.top_right, position.bottom_left, and so on). table.cell() addresses cells by column, then row, zero-indexed — reversing them is a common first bug. And the ta.atr() call lives in global scope, executed on every bar, while only the rendering is gated on barstate.islast; conditionally executing ta.* functions can desynchronize their internal history, so compute everywhere, draw once.

Why do tables slow scripts down? The recreate-every-bar mistake

The most common table bug is also the most expensive one. Written without var and without a barstate.islast gate, the code below runs its drawing logic on every historical bar:

// ❌ WRONG — recreates the table and redraws cells on every single bar.
// On a 20,000-bar chart this does 20,000 rounds of pointless drawing
// work, of which only the last is ever visible.
t = table.new(position.top_right, 2, 2)
table.cell(t, 0, 0, "Close")
table.cell(t, 1, 0, str.tostring(close))

What actually happens: on each bar, a brand-new table object is allocated, its cells are populated, and the previous bar's table is discarded. Pine's drawing garbage collection keeps the chart from filling with dead tables, but the work is still done thousands of times for a panel that can only ever display one state — the final one. On heavier scripts this is the difference between an indicator that loads instantly and one that crawls or hits TradingView's runtime limits.

The fix is the two-part idiom from the first section, and it is worth being explicit about what each part does. var table panel = table.new(...) runs its initializer once, on the first bar, so exactly one table object exists for the lifetime of the script. if barstate.islast restricts the table.cell() calls to the last available bar — during history loading that is the final historical bar, and in real time the block re-runs on each update of the live bar, keeping the panel current. One allocation, one visible state, work proportional to what the user actually sees. Every table example that follows uses this shape.

A complete live stats panel

Here is a fuller panel showing the pattern at practical scale — a header row plus three live metrics, with conditional coloring:

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

atrLen = input.int(14, "ATR length", minval = 1)

// Compute all series in global scope, every bar.
atrVal    = ta.atr(atrLen)
barChg    = 100 * (close - open) / open
volAvg    = ta.sma(volume, 20)
volRatio  = volume / volAvg

var table panel = table.new(position.top_right, 2, 4,
     bgcolor = color.new(color.black, 80),
     border_width = 1, border_color = color.new(color.gray, 70))

if barstate.islast
    table.cell(panel, 0, 0, "Metric", text_color = color.silver,
         text_size = size.small)
    table.cell(panel, 1, 0, "Value", text_color = color.silver,
         text_size = size.small)
    table.cell(panel, 0, 1, "ATR(" + str.tostring(atrLen) + ")",
         text_color = color.white)
    table.cell(panel, 1, 1, str.tostring(atrVal, format.mintick),
         text_color = color.white)
    table.cell(panel, 0, 2, "Bar change", text_color = color.white)
    table.cell(panel, 1, 2, str.tostring(barChg, "#.##") + "%",
         text_color = barChg >= 0 ? color.green : color.red)
    table.cell(panel, 0, 3, "Vol vs 20-bar avg", text_color = color.white)
    table.cell(panel, 1, 3, str.tostring(volRatio, "#.##") + "x",
         text_color = color.white)

Formatting notes: str.tostring(x, format.mintick) renders prices at the symbol's native tick precision, while a pattern string like "#.##" gives fixed decimals for ratios and percentages. Cell colors can be any expression — here the bar-change value flips green/red on sign. All numbers describe the current chart's history; none of them predict anything.

Adding a performance panel to a strategy

Tables pair naturally with strategy.* built-ins to put backtest statistics on the chart itself. The same rules apply — one var table, cells written on barstate.islast — plus one honest labeling rule: these are historical backtest numbers, so present them as such.

//@version=6
// Educational only — validate before trading; not financial advice.
strategy("SMA cross with stats panel", overlay = true)

fastLen = input.int(10, "Fast SMA", minval = 1)
slowLen = input.int(30, "Slow SMA", minval = 2)
stopPts = input.int(300, "Hard stop (points)", minval = 1)

fast = ta.sma(close, fastLen)
slow = ta.sma(close, slowLen)

if ta.crossover(fast, slow) and barstate.isconfirmed and strategy.position_size == 0
    strategy.entry("Long", strategy.long)
if ta.crossunder(fast, slow) and barstate.isconfirmed
    strategy.close("Long")

// Stop input wired into a real exit — no dead risk inputs.
if strategy.position_size > 0
    strategy.exit("Stop", "Long",
         stop = strategy.position_avg_price - stopPts * syminfo.mintick)

var table stats = table.new(position.bottom_right, 2, 4,
     bgcolor = color.new(color.black, 80))

if barstate.islast
    winRate = strategy.closedtrades > 0 ?
         100.0 * strategy.wintrades / strategy.closedtrades : na
    table.cell(stats, 0, 0, "Closed trades", text_color = color.white)
    table.cell(stats, 1, 0, str.tostring(strategy.closedtrades),
         text_color = color.white)
    table.cell(stats, 0, 1, "Win rate (hist.)", text_color = color.white)
    table.cell(stats, 1, 1, na(winRate) ? "n/a" :
         str.tostring(winRate, "#.#") + "%", text_color = color.white)
    table.cell(stats, 0, 2, "Open P/L", text_color = color.white)
    table.cell(stats, 1, 2, str.tostring(strategy.openprofit, format.mintick),
         text_color = color.white)
    table.cell(stats, 0, 3, "Net profit", text_color = color.white)
    table.cell(stats, 1, 3, str.tostring(strategy.netprofit, format.mintick),
         text_color = color.white)

Note the division guard — on a chart with zero closed trades, strategy.wintrades / strategy.closedtrades would divide by zero, so the ternary returns na and the cell prints "n/a". The panel label says "hist." deliberately: a historical win rate is a property of one backtest sample, not a forecast.

Pitfalls, limits, and honest framing

A short list of the remaining traps, then the caveat that belongs on every stats panel.

Column/row order. table.cell(tableId, column, row, ...) — column first. Swapping them produces a transposed or partially blank panel and no error, since both indices are usually in range.

Grid size is fixed at creation. table.new(position, cols, rows) allocates the full grid; writing to a cell outside it is a runtime error. If the number of rows depends on an input, size the table from that input on bar one, or create it generously and leave unused cells empty. table.merge_cells() can join a header across columns.

One table per `var`, not per condition. If you want the panel to disappear, do not create and delete tables conditionally — keep the single table and use table.clear() or set empty text, which is cheaper and avoids lifecycle bugs.

Realtime updates are free. Because barstate.islast is true on every tick of the live bar, the panel refreshes in real time with no extra code. If you want values only from confirmed bars in the panel, snapshot them with var variables updated when barstate.isconfirmed and render those.

*Don't compute `ta. inside the gate.** Repeated for emphasis because it is the subtle one: ta.` and `math. series calls belong in global scope so their internal state updates on every bar; the islast` block should only render.

Finally, the framing: a table can make a backtest look authoritative — neat numbers in a tidy panel. It is still just a display of historical output. This is educational material only, not financial advice; a displayed win rate or net profit is a description of one past sample, never a promise about live performance, and trading carries a real risk of loss.

Key takeaways

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

Catch the bug that compiles.Run auditGet Pro