Say you’re automating web orders for a Chicago outdoor store, and every order ID looks like ORD-2026-10-4471-CHI. You need the year, the month, the order number and the store code in separate columns, and the substring() expression pulls each piece out by position.
In this tutorial, I’ll show you how to use the power automate substring function with fixed positions, with indexOf() and lastIndexOf() when the positions move, and how to stop the out-of-range error that breaks so many flows.
I ran every expression below in a real cloud flow, so the results you see are the actual outputs.
The sample order ID we’ll use
My test flow, ESP Substring Demo, starts with a manual trigger and one Initialize variable action named OrderRef (type String). Every example reads that same value.
ORDat positions 0-2 (the prefix)2026at positions 4-7 (the year)10at positions 9-10 (the month)4471at positions 12-15 (the order number)CHIat positions 17-19 (the store code)
The whole string is 20 characters long, with dashes at positions 3, 8, 11 and 16. That length matters, because substring() fails the moment you ask for a character past position 19.

How substring() works
Here’s the syntax:
substring(text, startIndex, length)
- text (required): the string you want a piece of.
- startIndex (required): where to start. Counting starts at 0, so the first character is position 0.
- length (optional): how many characters to take. Leave it out to take everything to the end.
The result is always a string, even when it looks like a number. Wrap it in int() if you need to do math with it.
In the new designer, select the input box, choose fx, type the expression without the leading @, and select Add (Update when editing). If expressions are new to you, this list of Power Automate string functions is a good warm-up.

Method 1: Extract text at a fixed position (Recommended)
Use this when the format never changes, like our order IDs, a ZIP+4 code or a fixed-width date. Same OrderRef variable as before: the year starts at position 4 and runs 4 characters.
Here’s the expression:
substring(variables('OrderRef'), 4, 4)
That returns 2026. Change it to substring(variables('OrderRef'), 9, 2) and you get the month, 10. The run history shows both in quotes, "2026" and "10", because the value is still text.

How does this work?
Position 4 is the “2” right after the first dash. From there, substring() counts 4 characters: 2, 0, 2, 6. The month works the same way, starting at position 9 and taking 2 characters.
Here’s the Code view of that Compose action, if you’d rather paste it:
{
"type": "Compose",
"inputs": "@substring(variables('OrderRef'), 4, 4)",
"runAfter": {
"Initialize_OrderRef": ["Succeeded"]
}
}
Method 2: Leave out the length to take everything to the end
When you want the rest of the string, skip the third argument. Our store code starts at position 17 and is the last thing in the order ID.
substring(variables('OrderRef'), 17)
That returns CHI. Since there’s no length, there’s nothing that can run past the end, so this form is safer than guessing a length.

Need only the first or last character? There’s a shorter route in this guide to get the first character from a string.
Method 3: Find the start position with indexOf()
Real data isn’t always fixed width. Order prefixes might be ORD today and WEB or RETURN tomorrow. The indexOf() and lastIndexOf() functions find a character for you and return its position, so the expression adjusts on its own.
To get the text before the first dash, use the dash position as the length:
substring(variables('OrderRef'), 0, indexOf(variables('OrderRef'), '-'))
That returns ORD. To get everything after the first dash, start one character past it and leave out the length:
substring(variables('OrderRef'), add(indexOf(variables('OrderRef'), '-'), 1))
That returns 2026-10-4471-CHI.

How does this work?
indexOf(variables('OrderRef'), '-') returns 3, the position of the first dash. In the first expression, 3 becomes the length, so you get positions 0, 1 and 2.
In the second, add(3, 1) moves the start to position 4, just past the dash. This is the same trick behind extracting a string after a character.
Important: indexOf() isn’t case-sensitive, and it returns -1 when the character isn’t there. I’ll show you what -1 does to substring() in a minute.
Method 4: Get the last N characters with length()
When you know how many characters you want from the end, subtract them from the total. The length() function counts the characters, which is 20 for our OrderRef.
substring(variables('OrderRef'), sub(length(variables('OrderRef')), 3))
That returns CHI. If the last part can vary in size, use lastIndexOf() instead and take everything after the last dash:
substring(variables('OrderRef'), add(lastIndexOf(variables('OrderRef'), '-'), 1))
That also returns CHI, and it still works if a store code like NYC2 ever shows up.

How does this work?
sub(20, 3) gives 17, the position where CHI starts. lastIndexOf(..., '-') returns 16, the last dash, and adding 1 lands on 17 again.
Method 5: Grab a middle piece with nthIndexOf()
The order number sits after the third dash. nthIndexOf() finds the nth occurrence of a character, so you don’t have to chain several indexOf() calls.
substring(variables('OrderRef'), add(nthIndexOf(variables('OrderRef'), '-', 3), 1), 4)
That returns 4471 (shown as "4471" in the run, because it’s text).

How does this work?
nthIndexOf(variables('OrderRef'), '-', 3) returns 11, the third dash. Adding 1 starts at position 12, and a length of 4 takes 4, 4, 7, 1.
If you need every piece at once, the split() function is simpler: split(variables('OrderRef'), '-') gives an array, and split(variables('OrderRef'), '-')[3] returns the order number.
Pro Tip: If the text comes from a SharePoint list column instead of a variable, swap variables('OrderRef') for the dynamic value, such as triggerOutputs()?['body/Title']. The rest of the expression stays the same.
Fix the substring() out-of-range error
If startIndex plus length goes past the end of the string, substring() doesn’t trim the result like Excel’s MID does. The action fails.
substring(variables('OrderRef'), 17, 5)
Our string has only 3 characters after position 17, so the Compose action fails with this message:
InvalidTemplate. The template language function 'substring' parameters are out of range: 'start index' and 'length' must be non-negative integers and their sum must be no larger than the length of the string.

Asking for exactly the rest is fine: substring(variables('OrderRef'), 0, 20) returns the full 20-character ID. So the real rule is that start plus length can’t be larger than the length, which matches the error text above.
To cap the length so it never runs past the end, use min():
substring(variables('OrderRef'), 17, min(5, sub(length(variables('OrderRef')), 17)))
That returns CHI instead of an error.
How does this work?
sub(20, 17) is 3, the characters actually left. min(5, 3) picks 3, so the length can never be bigger than what’s there.
Handle a missing delimiter, empty text and null
Same OrderRef variable, but now we look for a # that isn’t in it. indexOf() returns -1, and a length of -1 fails the action with the same out-of-range error:
substring(variables('OrderRef'), 0, indexOf(variables('OrderRef'), '#'))
Wrap it in an if() expression so the flow checks first and falls back to the whole value:
if(greater(indexOf(variables('OrderRef'), '#'), -1), substring(variables('OrderRef'), 0, indexOf(variables('OrderRef'), '#')), variables('OrderRef'))
That returns ORD-2026-10-4471-CHI, and the run keeps going.

Here’s what the other edge cases returned in my run:
| Expression | Result |
|---|---|
substring(variables('OrderRef'), 25) | Fails: start index out of range |
substring(variables('OrderRef'), -3) | Fails: start index out of range |
substring('', 0) | Fails: start index out of range |
substring(null, 0, 3) | Fails: expects a string, got Null |
substring(coalesce(null, ''), 0) | Still fails: ” has no position 0 |
The start-index errors read: The template language function 'substring' parameter is out of range: 'start index' must be non-negative integer and should be less than the length of the string.
So here’s the catch: coalesce() alone doesn’t save you. It turns null into an empty string, and an empty string has no position 0, so substring() still fails. Check for empty text first:
if(empty(triggerOutputs()?['body/Title']), '', substring(triggerOutputs()?['body/Title'], 0, 3))
I tested the pattern in a second flow: if(empty(null), 'no value', substring(null, 0, 3)) returned no value, and if(empty(''), '', substring('', 0)) returned an empty string. if() only runs the branch it picks, so the failing substring() never fires.
substring() vs slice()
The slice() function also returns part of a string, but it takes an end position instead of a length. It’s also more forgiving.
| Same OrderRef | substring() | slice() |
|---|---|---|
| Year | substring(variables('OrderRef'), 4, 4) | slice(variables('OrderRef'), 4, 8) returns 2026 |
| Last 3 characters | substring(..., sub(length(...), 3)) | slice(variables('OrderRef'), -3) returns CHI |
| Ask past the end | Error | slice(variables('OrderRef'), 17, 50) returns CHI |

For the first few characters, take(variables('OrderRef'), 3) returns ORD with no index math at all.
Pro Tip: I still use substring() when the length is fixed, because the expression reads clearly. When the end might move, I reach for slice().
Which method should you use?
| You want to | Use |
|---|---|
| Pull a piece from a fixed-width code | Method 1: substring(text, start, length) |
| Take everything from a position on | Method 2: leave out the length |
| Get text before or after a character | Method 3: indexOf() |
| Get the last N characters | Method 4: length() or lastIndexOf() |
| Get a piece between two delimiters | Method 5: nthIndexOf() or split() |
| Avoid errors on short or messy text | slice(), or min() and if() guards |
For cleanup jobs rather than extraction, like stripping dashes or spaces, see how to remove characters from a string in Power Automate.
Things to Keep in Mind
- Positions start at 0: the first character is position 0, not 1. Count before you type.
- Too long fails: start plus length past the end stops the flow. Cap it with min() or use slice().
- -1 from indexOf(): a missing character turns into -1 and breaks substring(). Check with if() first.
- Null or empty input: both fail, even after coalesce(). Wrap the call in if(empty(…), ”, substring(…)).
- No @ in the fx editor: type the expression without it; the designer adds the @ you see in Code view.
If you work with text in flows a lot, these will help too:
Frequently Asked Questions
Is the substring() index in Power Automate zero-based?
Yes. The first character is at position 0, so substring(‘ORD-2026’, 0, 3) returns ORD.
Is the length argument required in substring()?
No. If you leave it out, substring() returns every character from the start position to the end of the string.
Why does my substring() expression fail?
Usually the start position plus the length is longer than the text, or indexOf() returned -1. Microsoft lists the rules in the substring() function reference.
How do I get the last 4 characters of a string in Power Automate?
Use substring(text, sub(length(text), 4)), or the shorter slice(text, -4).
Does Power Automate Desktop use the same substring() function?
No. Desktop flows use the Get subtext and Crop text actions instead of the cloud flow expression.

Preeti Sahu is a Microsoft MVP for Power Apps with over six years of experience working with SharePoint Online and the Power Platform. She is the co-author of the book Microsoft Power Platform: A Deep Dive. As a Power Platform developer, she has built various tools with Power Apps and Power Automate, and she makes Microsoft 365 videos for YouTube.