Tutorial

Pine Script str.format: Build Labels and Alert Text

Learn how str.format builds readable labels, table cells and alert text in Pine Script v6, with number patterns, dates, and brace escaping.

•8 min read

Sooner or later every indicator needs to say something in words. A label that shows the RSI value and the bar's volume, an alert that names the symbol and the price, a table cell that reads "Up 2.35%" instead of a raw float with fourteen decimals. Most people reach for the plus operator and chain string pieces together, and it works until the line becomes a wall of quotes, str.tostring calls and commas that nobody wants to edit. This guide shows how str.format replaces that pattern with a template you can read at a glance, and how to control rounding, percentages, dates and escaping so your text looks the way you intend.

Why concatenation gets painful fast

Here is the pattern most scripts start with. You build a message by joining literals and converted numbers:

pine
//@version=6
indicator("Concatenation baseline", overlay = true)

rsiValue = ta.rsi(close, 14)

if barstate.islast
    msg = syminfo.ticker + " | RSI " + str.tostring(rsiValue, "#.0") + " | Close " + str.tostring(close, format.mintick)
    label.new(bar_index, high, msg, style = label.style_label_down)

It compiles and it is fine for two values. The trouble shows up when you add a third and fourth value, when you want to move the text around, or when you want to reuse the same wording in a label and an alert. The sentence is spread across the code and you have to mentally assemble it to know what the reader will see. A template keeps the whole sentence in one place and pushes the values to the end of the call.

How str.format works

str.format(formatString, arg0, arg1, ...) takes a template string and any number of arguments. Inside the template, {0} stands for the first argument after the template, {1} for the second, and so on. The numbering starts at zero, which is the single most common source of off-by-one mistakes. The same index can appear more than once, and the placeholders do not need to appear in order, which is useful when you translate or reword a message without touching the argument list.

pine
//@version=6
indicator("str.format basics", overlay = true)

rsiValue = ta.rsi(close, 14)

if barstate.islast
    msg = str.format("{0} | RSI {1} | Close {2}", syminfo.ticker, rsiValue, close)
    label.new(bar_index, high, msg, style = label.style_label_down)

Run this and you will notice the numbers come out with many decimals, because the template did not say how to print them. That is where the placeholder modifiers come in. The argument types can be mixed freely: strings, ints, floats and bools all work in the same call.

Controlling number formats

A placeholder can carry a type and a pattern after the index, separated by commas: {0,number,pattern}. The pattern uses the familiar # and 0 digit symbols. A few patterns cover most needs.

The pattern #.## shows up to two decimals and drops trailing zeros, so 1.50 prints as 1.5. The pattern 0.00 always shows exactly two decimals, so 1.5 prints as 1.50. The pattern #,###.00 adds thousands separators, which makes volume readable. The keyword integer rounds to a whole number, and percent multiplies the value by 100 and appends a percent sign.

pine
//@version=6
indicator("Number patterns", overlay = true)

changeRatio = close / close[1] - 1

if barstate.islast
    msg = str.format("Close {0,number,0.00}\nVolume {1,number,#,###}\nChange {2,number,#.##%}\nBars {3,number,integer}",
         close, volume, changeRatio, bar_index)
    label.new(bar_index, high, msg, style = label.style_label_down)

The percent case deserves a note. A bare percent modifier prints a whole number, so a 0.0235 change shows as 2%. If you want the decimals, use a pattern that ends in a percent sign, such as #.##%, and pass the ratio, not a value you already multiplied by 100. Multiplying twice is a classic way to end up with a label that says 235%.

Rounding behaves the way the pattern implies, so a value of 2.345 with 0.00 can print as 2.34 or 2.35 depending on rounding rules. If exact rounding matters for what you show, round the number first with math.round(value, 2) and then format it.

Prices: when to use format.mintick instead

For prices, a fixed pattern is often the wrong tool. A crypto pair quoted to six decimals and a stock quoted to two need different precision, and hardcoding 0.00 will cut the first one short. The built-in format.mintick exists for this. You pass it to str.tostring, and it rounds to the symbol's minimum tick:

pine
//@version=6
indicator("Mintick formatting", overlay = true)

if barstate.islast
    priceText = str.tostring(close, format.mintick)
    msg = str.format("{0} closed at {1}", syminfo.ticker, priceText)
    label.new(bar_index, high, msg, style = label.style_label_down)

Converting the price to text first and passing the string as an argument is a clean way to combine the two tools. The template stays readable and the price follows the symbol's own precision, so the same script works across instruments.

Dates and times in text

Timestamps are another place where plain concatenation hurts. The function str.format_time(time, format, timezone) converts a millisecond timestamp into text using a pattern such as "yyyy-MM-dd HH:mm". The timezone argument is optional, and when you leave it out the exchange timezone of the symbol is used. If you want a stable timestamp that does not depend on the symbol, pass "UTC".

pine
//@version=6
indicator("Timestamp in text", overlay = true)

if barstate.islast
    stamp = str.format_time(time, "yyyy-MM-dd HH:mm", "UTC")
    msg = str.format("Last bar opened {0} UTC", stamp)
    label.new(bar_index, high, msg, style = label.style_label_down)

Format the time once, store it in a variable, and feed it into the template. That keeps the template free of date logic and makes it easy to change the date pattern later. Remember that time is the opening time of the bar, so if your message says "closed at" the wording should match the timestamp you are using.

Building alert text with str.format

Dynamic alert text is where str.format earns its keep. There are two alert mechanisms and they treat text differently. With alertcondition, the message must be a constant string, so you use TradingView's placeholders like {{ticker}} and {{close}}, which are filled in when the alert fires. With alert(), the message is a series string you build at runtime, so you can include any computed value. That is the case for str.format. The difference between the two, and when to choose each, is covered in alertcondition vs alert() in Pine Script.

pine
//@version=6
indicator("Dynamic alert text", overlay = true)

fastMa = ta.ema(close, 9)
slowMa = ta.ema(close, 21)
crossedUp = ta.crossover(fastMa, slowMa)

if crossedUp
    alertText = str.format("{0} {1}: fast EMA crossed above slow EMA. Close {2}, gap {3,number,0.0000}",
         syminfo.ticker, timeframe.period, str.tostring(close, format.mintick), fastMa - slowMa)
    alert(alertText, alert.freq_once_per_bar_close)

Using alert.freq_once_per_bar_close means the alert waits for the bar to confirm, which avoids messages for crossovers that disappear before the bar closes. If you want to understand why alerts sometimes fire on the wrong bar, see Why Your Pine Script Alert Fires on the Wrong Bar. For a fuller treatment of structuring alerts in a larger script, Building a Repaint-Free Alert System in Pine Script goes further.

One practical habit helps here: build the message string once and reuse it. If the same text goes into a label and an alert, define a variable with the formatted string and pass it to both. Then the two can never drift apart.

Escaping braces and quotes

Because curly braces mean "placeholder", you need a way to print a literal brace, for example when your alert message is JSON for a webhook. The template syntax treats the single quote as an escape character: text wrapped in single quotes is output as is. So to print a literal opening brace, write '{', and for a closing brace '}'.

pine
//@version=6
indicator("Webhook JSON text", overlay = true)

if barstate.islast
    payload = str.format("'{'\"symbol\": \"{0}\", \"price\": {1}'}'", syminfo.ticker, str.tostring(close, format.mintick))
    log.info(payload)

This is easy to get wrong, and a single unescaped brace changes the meaning of the template. If your output looks like it dropped a chunk of text, check the quotes and braces first. The same rule applies to a literal single quote: the escape character itself is doubled to print one. For webhook payloads specifically, the TradingView Alert Webhook Builder is a quicker way to check the structure of the JSON before you wire it into a script.

Putting it into a table cell

Tables are another natural home for formatted strings, because each cell takes a single text value. Format first, then write the cell:

pine
//@version=6
indicator("Formatted table", overlay = true)

var table info = table.new(position.top_right, 2, 2, border_width = 1)
rsiValue = ta.rsi(close, 14)
changeRatio = close / close[1] - 1

if barstate.islast
    table.cell(info, 0, 0, "RSI")
    table.cell(info, 1, 0, str.format("{0,number,0.0}", rsiValue))
    table.cell(info, 0, 1, "Change")
    table.cell(info, 1, 1, str.format("{0,number,#.##%}", changeRatio))

For a full dashboard layout, see Pine Script Table Objects: Building a Stats Dashboard on Your Chart. Labels have their own limits on how many can exist at once, which matters if you build text for every bar; Pine Script Label and Line Limits explains the cap and how to work around it.

Common mistakes

The first mistake is the zero-based index. If your output shows the wrong value in the wrong slot, count again from zero. The second is passing na and being surprised by the result, because a missing value does not stop the script, it just shows up in your text as something unhelpful. Guard the call with na() checks or use nz where a default makes sense. How Pine Script Handles na walks through how missing values travel through a script.

The third is formatting inside a loop on every bar when you only need the text on the last bar. Wrapping the call in barstate.islast or in the condition that triggers the message keeps the script fast. The fourth is forgetting that alertcondition cannot take a series string, so a dynamically formatted message belongs in alert(). The fifth is trusting the display without testing; run the script on a real chart and read the output. For debugging text that does not appear, log.info is a quick way to see exactly what string you built, as shown in Runtime Logging in Pine Script.

Getting the formatting written for you

If you would rather describe the message than hand-write the template, PineScripter's AI code editor can write the label or alert text from a plain-English description such as "show the symbol, RSI to one decimal and the percent change on the last bar". It checks the script the way TradingView does and fixes compile errors itself, so a misplaced brace or a wrong modifier does not turn into a copy-and-paste loop. You still read the result and decide whether it says what you want. If you already have an indicator and need to add alerts to it, Add Alerts to a Complex Pine Script Indicator Safely covers that workflow.

Summary

Use str.format when a message has more than two values, use {n,number,pattern} to control decimals and percentages, use format.mintick through str.tostring for prices, use str.format_time for timestamps, and escape braces with single quotes when you need literal braces. Build the text once, reuse it, and format only when you will actually show it.

PineScripter is a coding tool, not a financial advisor. Nothing here is financial advice, and the examples are for learning Pine Script, not predictions or recommendations.

Frequently asked questions

Does str.format start counting placeholders at 0 or 1?

At 0. {0} is the first argument after the template string, {1} the second, and so on.

Can I use str.format in alertcondition?

No. alertcondition needs a constant message and uses TradingView placeholders like {{close}}. Use alert() when you want to build the message at runtime with str.format.

How do I show a percent change with decimals?

Pass the ratio, not a value already multiplied by 100, and use a pattern ending in a percent sign, such as {0,number,#.##%}.

How do I print a literal curly brace?

Wrap it in single quotes inside the template, for example '{' and '}', which is useful for JSON webhook messages.

What is the best way to format a price?

Use str.tostring(price, format.mintick) so the precision follows the symbol, then pass the resulting string into str.format.

Describe your indicator in plain English and get Pine Script that compiles →


Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.