Pine Script Series Data Type Explained: How Time-Series Storage Works Bar-by-Bar
In Pine Script, every value you work with — whether a price, a calculated indicator, or a boolean flag — is stored not as a single scalar, but as a continuous, ordered stream of values indexed by bar position. This architectural choice is what makes Pine Script fundamentally different from general-purpose languages, and understanding the series data type is the key to writing correct, efficient, and predictable indicators and strategies.
⚠️ Educational Disclaimer: All code examples in this article are for educational purposes only. Always test scripts on historical, non-live data before deploying them in any real environment.
1. What Is a Series?
A series in Pine Script is a sequence of values, one per historical bar, stored in a contiguous buffer. Think of it as an implicit array where index 0 always refers to the current bar, index 1 refers to the previous bar, and so on. The built-in history-reference operator [] is the primary mechanism for accessing past values.
Mathematically, if we denote a series as $S$, then:
$$S[0] = \text{current bar value}, \quad S[1] = \text{one bar ago}, \quad S[n] = \text{n bars ago}$$
This is not a random-access array you allocate manually — Pine Script manages the buffer automatically, extending it as each new bar is processed.
2. The Type System: Base Types and Qualifiers
Pine Script's type system has two orthogonal dimensions: base types and qualifiers. It is critical to understand that these are separate concepts.
2.1 Base Types
Base types describe what kind of data a value holds:
| Base Type | Description | Example |
|---|---|---|
int |
Integer number | bar_index |
float |
Floating-point number | close, ta.sma() |
bool |
Boolean true/false | close > open |
string |
Text value | syminfo.ticker |
color |
Color value | color.red |
2.2 Qualifiers
Qualifiers describe when a value is known — i.e., at what stage of script execution the value is determined. From most to least dynamic:
| Qualifier | When Known | Example |
|---|---|---|
const |
Compile time | const int MAX = 100 |
input |
Script load time (user input) | input.int(14) |
simple |
First bar execution | syminfo.mintick |
series |
Every bar (most dynamic) | close, ta.rsi() |
A key rule: A value with a more dynamic qualifier generally cannot be used where a less dynamic qualifier is required. However, many built-in functions in modern Pine Script versions accept series arguments even for parameters that were previously restricted to simple types. Compile-time errors only occur when a function explicitly requires a less dynamic qualifier and does not support series inputs.
🔽 [Click to expand] Qualifier Mismatch Example
//@version=6
indicator("Qualifier Mismatch Demo", overlay=false)
// This is a series int — its value changes on every bar
series int dynamicLength = math.max(5, bar_index % 20 + 5)
// ❌ COMPILE ERROR: ta.sma() requires a 'simple int' length,
// but dynamicLength is a 'series int'.
// float result = ta.sma(close, dynamicLength)
// ✅ CORRECT: Use a fixed input value (input qualifier)
simple int fixedLength = input.int(14, "Length", minval=1)
float result = ta.sma(close, fixedLength)
plot(result, "SMA", color=color.blue)
3. How the Series Buffer Works Internally
Pine Script maintains a rolling buffer for each series variable. The buffer depth is determined by the maximum lookback your script requires. When you write close[50], Pine Script must retain at least 51 bars of close data in memory.
The default buffer size is automatically inferred by the compiler. However, when the required lookback depth cannot be determined at compile time — for example, when the lookback index is itself a series — you must declare the maximum buffer size explicitly using the max_bars_back parameter on indicator() or strategy().
🔽 [Click to expand] max_bars_back Parameter Demo
//@version=6
// The max_bars_back parameter on indicator() tells Pine Script
// to allocate a buffer of at least 500 bars for ALL series in this script.
// Use this when the compiler cannot statically determine the required lookback depth.
indicator("Series Buffer Demo", overlay=true, max_bars_back=500)
// A simple input-qualified length
simple int len = input.int(20, "SMA Length", minval=1)
// ta.sma() internally accesses close[0] through close[len-1]
// The buffer must hold at least 'len' bars of close data.
float smaValue = ta.sma(close, len)
// Accessing a fixed historical offset — requires buffer depth >= 100
float closeHundredBarsAgo = close[100]
// Plotting both values
plot(smaValue, "SMA", color=color.orange)
plot(closeHundredBarsAgo, "Close 100 Bars Ago", color=color.gray)
The max_bars_back parameter is a compile-time directive — it instructs the runtime to pre-allocate buffer space. It does not dynamically resize buffers during execution. If your script accesses history beyond the allocated depth, a runtime error will occur.
4. The [] History-Reference Operator
The square-bracket operator [] is the only mechanism for accessing historical values of a series. Its argument must be a non-negative integer. The offset is always relative to the current bar:
$$\text{value}[n] \equiv \text{the value of 'value' exactly } n \text{ bars before the current bar}$$
🔽 [Click to expand] History Reference Operator Examples
//@version=6
indicator("History Reference Operator", overlay=false)
// close[0] is identical to close — current bar's close
float currentClose = close[0]
// close[1] is the previous bar's close (yesterday on daily chart)
float previousClose = close[1]
// Manually computing a 3-bar simple moving average using [] operator
// SMA(3) = (close[0] + close[1] + close[2]) / 3
float manualSma3 = (close[0] + close[1] + close[2]) / 3.0
// Verify against built-in ta.sma()
float builtinSma3 = ta.sma(close, 3)
// The difference should be zero (or floating-point epsilon)
float diff = math.abs(manualSma3 - builtinSma3)
plot(manualSma3, "Manual SMA(3)", color=color.blue, linewidth=2)
plot(builtinSma3, "Builtin SMA(3)", color=color.red, linewidth=1, style=plot.style_circles)
plot(diff, "Difference", color=color.green, linewidth=1)
5. var and varip: Controlling Series State
By default, a variable declared with = is re-initialized on every bar. To accumulate state across bars, Pine Script provides two special declaration keywords:
| Keyword | Initialization | Persistence | Typical Use Case |
|---|---|---|---|
= (standard) |
Every bar | None — resets each bar | Per-bar calculations |
var |
First historical bar only | Persists across bars | Accumulators, counters, state machines |
varip |
First historical bar only | Persists across bars and intrabar ticks | Tick-level counters in real-time |
🔽 [Click to expand] var vs Standard Declaration Demo
//@version=6
indicator("var vs Standard Declaration", overlay=false)
// Standard declaration: resets to 0 on EVERY bar
// This will always equal 1 after the increment, never accumulate
int standardCounter = 0
standardCounter := standardCounter + 1
// standardCounter is always 1 here
// var declaration: initialized ONCE on the first bar, then persists
// This correctly counts the total number of bars processed
var int persistentCounter = 0
persistentCounter := persistentCounter + 1
// persistentCounter equals bar_index + 1 here
// Verify: persistentCounter should equal bar_index + 1
bool isCorrect = persistentCounter == bar_index + 1
plot(persistentCounter, "Persistent Counter", color=color.blue)
plot(standardCounter, "Standard Counter", color=color.red)
// isCorrect should always be true — plot as 1.0 or 0.0
plot(isCorrect ? 1.0 : 0.0, "Is Correct?", color=color.green)
6. Series in Loops: The Critical Constraint
A fundamental architectural rule in Pine Script is that for and while loops do not return values. You cannot assign a loop construct directly to a variable. To extract a result from a loop, you must declare a variable before the loop and update it inside the loop using :=.
🔽 [Click to expand] Correct Loop Pattern with Series
//@version=6
indicator("Loop with Series — Correct Pattern", overlay=false)
simple int period = input.int(10, "Period", minval=1)
// ❌ ILLEGAL: You cannot assign a for loop directly to a variable
// float result = for i = 0 to period - 1 ...
// ✅ CORRECT PATTERN:
// Step 1: Declare the accumulator variable BEFORE the loop
float sumOfCloses = 0.0
// Step 2: Run the loop, updating the accumulator with :=
for i = 0 to period - 1
// Access historical close values using the [] operator
sumOfCloses := sumOfCloses + close[i]
// Step 3: Use the result after the loop
float manualMean = sumOfCloses / period
// Cross-verify with built-in
float builtinMean = ta.sma(close, period)
float error = math.abs(manualMean - builtinMean)
plot(manualMean, "Manual Mean", color=color.blue)
plot(builtinMean, "Builtin Mean", color=color.orange, linewidth=2)
plot(error, "Error", color=color.red)
7. Series and User-Defined Types (UDTs)
Pine Script v6 supports User-Defined Types (UDTs) via the type keyword. Fields within a UDT instance are themselves series — each field maintains its own per-bar history buffer. This enables you to encapsulate multi-dimensional state in a single object.
You can also build arrays of UDT instances using array<MyType>. Arrays of arrays are supported for constructing more complex data structures. Note that Pine Script does not have a native matrix type for custom types — more complex matrix-like structures must be emulated using arrays of arrays.
🔽 [Click to expand] UDT Series Fields Demo
//@version=6
indicator("UDT with Series Fields", overlay=false)
// Define a User-Defined Type to hold OHLC snapshot data
type OhlcSnapshot
float o // open
float h // high
float l // low
float c // close
// Instantiate the UDT — each field is a series float
var OhlcSnapshot snap = OhlcSnapshot.new()
// Update fields on every bar
snap.o := open
snap.h := high
snap.l := low
snap.c := close
// Access historical field values using [] on the UDT instance's fields
// snap.c[1] is the close of the previous bar
float prevClose = na(snap[1]) ? na : (snap[1]).c
float bodySize = math.abs(snap.c - snap.o)
// Demonstrate array of UDTs
// Build a small array of OhlcSnapshot objects for the last 3 bars
var array history = array.new(3)
array.set(history, 0, OhlcSnapshot.new(open, high, low, close))
array.set(history, 1, OhlcSnapshot.new(open[1], high[1], low[1], close[1]))
array.set(history, 2, OhlcSnapshot.new(open[2], high[2], low[2], close[2]))
// Compute average body size over the 3 stored snapshots
float avgBody = 0.0
for i = 0 to 2
OhlcSnapshot s = array.get(history, i)
avgBody := avgBody + math.abs(s.c - s.o)
avgBody := avgBody / 3.0
plot(bodySize, "Current Body", color=color.blue)
plot(avgBody, "Avg Body (3 bar)",color=color.orange)
plot(prevClose,"Prev Close", color=color.gray)
8. Practical Performance Considerations
Because every series variable maintains a historical buffer, excessive use of deep history references can increase memory consumption. The following guidelines help keep scripts efficient:
| Practice | Recommendation |
|---|---|
Deep history access (close[500]) |
Set max_bars_back on indicator() or strategy() to the required depth |
| Accumulating state | Use var to avoid re-initialization overhead every bar |
| Repeated identical calculations | Cache results in a named variable rather than recomputing inline |
| Large arrays of UDTs | Limit array size; prefer built-in rolling functions where possible |
9. Conclusion
The series data type is not merely a storage mechanism — it is the execution model of Pine Script itself. Every calculation, every condition, every plot is evaluated bar-by-bar against a stream of historical values. Mastering the series concept means understanding:
- Base types vs. qualifiers are orthogonal:
series floatmeans a floating-point value that changes every bar. Qualifier mismatches (e.g., passing aseries intwheresimple intis required) are caught at compile time, not runtime. - Buffer management is explicit when needed: Use the
max_bars_backparameter onindicator()orstrategy()when the compiler cannot statically determine the required lookback depth. This is a compile-time directive that pre-allocates buffer space. - State persistence requires intentional declaration: Use
varfor cross-bar accumulators andvaripfor intrabar tick persistence. Standard=declarations reset on every bar.
Ideas for Script Advancement
- Dynamic Lookback with Conditional Buffering: Build an indicator that uses
input.int()to let users specify a lookback period up to 500 bars, and setmax_bars_back=500onindicator()to guarantee the buffer is always sufficient regardless of user selection. - UDT-Based State Machine: Implement a multi-state trend detector using a UDT with
varpersistence, where each field tracks a different aspect of market state (e.g., trend direction, bar count in trend, peak/trough price). Use arrays of UDT instances to maintain a rolling window of state snapshots for pattern recognition.
Comments
Post a Comment