Optimizing Pine Script
How to make a Pine Script indicator or strategy run faster: measure where the time goes, use what Pine already does natively, and remove the work that does not change the result.
A Pine script runs once on every historical bar, and an indicator runs again on every price update of the realtime bar. Whatever a line costs, it pays that cost tens of thousands of times. When a script takes too long, TradingView stops it with an error, so in large scripts optimization decides whether a feature can exist at all.
Measure first
Guessing where the time goes is usually wrong. TradingView's Pine Profiler, available from the Pine Editor, runs the script and reports how much of the total runtime each line and code block took.
- 1Profile on a realistic chart
Use the symbol, timeframe and amount of history the script will normally run on. Costs that scale with bars or with collection size only show up there.
- 2Find the few lines that dominate
Most of the time usually sits in a handful of places: a loop, an expensive function, a rebuild. Ignore everything else for now.
- 3Ask whether that work changes the result
Work that repeats an unchanged answer, moves data that stays the same, or visits entries that did not change can be removed without changing any output.
- 4Change one thing, then profile again
Confirm the time dropped and the plotted values are identical. Keep the change only if both hold.
Native Pine first
Built-in functions run inside TradingView's engine and are faster than the same logic written as a Pine loop. Before restructuring anything, check that the script uses them:
- Series history is already O(1).
close[50]reads a past value directly. Do not copy prices into an array to look back. - Prefer built-ins to loops.
ta.highest(high, 50)instead of a loop overhigh[i];array.sum(),array.max(),array.sort()andarray.binary_search()instead of hand-written versions. - Call each indicator once. Every
ta.sma(close, 20)call keeps its own state and computes separately. Assign the result to a variable and reuse it. - Combine requests. Several values from the same symbol and timeframe can come back from one
request.security()call as a tuple.
[dailyClose, dailyAtr] = request.security(syminfo.tickerid, "D", [close, ta.atr(14)])Do less on historical bars
- Tables and dashboards only show their final state, so fill them on the last bar with
barstate.islast. - Drawings can be created once with
varand moved with their setter functions, instead of being deleted and created again. - Event logic that only matters on closed bars can wait for
barstate.isconfirmed, so it does not rerun on every realtime update. - Long histories that the script does not need can be cut with the
calc_bars_countargument ofindicator().
var table stats = table.new(position.top_right, 2, 1)
// The ATR runs on every bar; only the table waits for the last one.
float atr14 = ta.atr(14)
if barstate.islast
table.cell(stats, 0, 0, "ATR")
table.cell(stats, 1, 0, str.tostring(atr14, format.mintick))Work that adds nothing
Once the native basics are in place, what remains in a demanding script is usually structural: work around the calculation, repeated on every bar. Each pattern below has a replacement that produces the same result.
| What the profiler shows | What it usually means | Replace it with |
|---|---|---|
| An expensive pure function on every bar | Its inputs repeat, so it recomputes the same answer | Memo or Watch |
| A model refit or summary on every bar | It could run every few bars without harm | CadenceGate |
array.shift() on a full rolling array | Every new entry moves all the others | A row ring |
array.remove() across parallel arrays | Removing one object shifts every later one, in every array | StablePool |
| A loop over every entry to find the changed ones | Most of the visits find nothing | DirtySet |
array.indexof() or a loop to find a key | A linear search per lookup | A typed store or IntIndex |
| A map of wrapper objects that each hold an array | One small array per key | IntBuckets |
array.new() inside the bar | A fresh allocation on every execution | var arrays, output arrays and double buffers |
In our tests, these replacements commonly ran 15% to 40% faster than the conventional versions, and over 90% faster for sparse updates and keyed lookups against a full scan or search. A replacement only helps where the original work dominates, which is what the profiler is for.
Example: a rolling event history
A script keeps the last 200 pivot highs. The usual version pushes each new pivot and shifts the oldest one out:
float pivot = ta.pivothigh(high, 5, 5)
var array<float> pivots = array.new<float>()
if not na(pivot)
pivots.push(pivot)
if pivots.size() > 200
pivots.shift()
float latestPivot = pivots.size() > 0 ? pivots.last() : naA ring holds the same 200 values and overwrites the oldest one in place:
import Alien_Algorithms/OptiPine/1 as op
float pivot = ta.pivothigh(high, 5, 5)
var op.FloatRowRing pivots = op.floatRowRing(200, 1)
if not na(pivot)
pivots.pushValue(pivot)
float latestPivot = pivots.newestAt(0, 0)Reads stay in time order: pivots.at(0, 0) is the oldest retained pivot and newestAt(0, 0) the latest, or na before the first one. Pivots are events, which is why they belong in a ring; a price on every bar is already in the series history.
Loops
- Move work that does not depend on the loop variable out of the loop.
- Leave with
breakas soon as the answer is known. - Avoid a loop inside a loop over the same collection. A lookup structure usually turns the inner loop into one call.
- Prefer batch calls such as
getMany(),findMany()ormarkMany(), which dispatch once for the whole batch. - Guard counted loops over a range that may be empty:
for i = 0 to n - 1counts down whennis 0.
Stay correct while optimizing
Keep history-dependent calls on every bar
ta.* functions keep their own history. Inside a condition that is false on some bars, they miss those bars and return different values. Pine Script v6 also evaluates and and or lazily, so a call on the right of and can be skipped too. Compute these on every bar and pass the results into whatever is guarded.
Cache on what matters
A cache keyed on raw prices misses on almost every bar. Round the inputs to the precision the calculation actually responds to, and the expensive part runs only when that changes. Watch & Memo shows the pattern.
Prove the output is unchanged
Plot the old and new values together, or both to the Data Window, across the whole history. An optimization that changes a value is a different indicator.
Checklist
- Profile on a realistic chart and start with the costliest lines.
- Use series history and built-in functions before writing loops.
- Call each
ta.*function once, on every bar, outside any condition. - Fill tables and final drawings on the last bar; reuse drawing objects.
- Cache pure results on rounded inputs.
- Replace shifting, removal, full scans and linear search with the structures above where the profiler shows them.
- Create collections once with
varand reuse output arrays. - Confirm the output is identical after every change.
The Quickstart sets up OptiPine in a script, and the complete examples combine several of these structures in working indicators.