Tabs to spaces converter
A tab at the start of a line becomes 4 spaces, but a tab after one character becomes 3: each tab jumps to the next tab stop, so alignment survives and the file ends up space-indented. Free in your browser.
- Free
- Runs in your browser
- No sign-up
- Your data stays on your device
Run — free
Worked example
- Text
- function hi() { return true; }
- Tab size
- 4
- Text
- function hi() { return true; }
- Tabs replaced
- 1
- Spaces added
- 4
- Chars before
- 31
Mixed tab and space indentation is one of the most common style fights in software teams, and also one of the quietest sources of broken diffs, broken patches, and broken continuous-integration linters. A tabs to spaces converter takes source text that still contains tab characters and rewrites every tab as spaces so the visual columns stay the same while the file becomes pure space-indented. This capability walks each line from left to right, advances a column counter for every character, and when it hits a tab it inserts just enough spaces to reach the next tab stop of your chosen width. Leading block indentation and mid-line alignment tabs are both expanded. Existing space characters are left alone, so you never collapse intentional multi-space gaps.
How tab stops expand tabs into spaces without losing alignment
A tab character is not a fixed number of spaces; it is a jump to the next multiple of the tab size measured from the start of the line. Editors and terminals use that rule so that a tab typed after a short label and a tab typed after a long label both land on the same column grid.
Leading indentation that was written as one or more tabs therefore becomes a clean multiple of spaces, and inline tabs used for table-like alignment in comments or parameter lists keep their visual columns instead of turning into a blunt replacement of every tab with the same fixed run of spaces. Carriage return characters are preserved for CRLF files without resetting the column until the line feed, so Windows-style line endings do not scramble the column math.
Where converting tabs to spaces pays off
Style enforcement pipelines often need a deterministic rewrite step before formatters that assume spaces-only indentation, or after imports from editors that still emit hard tabs. Documentation sites and coding challenge platforms that accept student submissions can normalize tabs server-side so auto-graders compare pure space files and never fail on invisible tab differences. Content teams ranking for tabs to spaces converter benefit from a live free browser path that matches the paid automation path byte for byte, so marketing copy and engineering runbooks never disagree.
Failed validation does not consume balance.
What you can do with it
Homework and LMS submissions
Normalize student source on upload so auto-graders compare space-only files and never fail on invisible tab characters.
FAQ
How does the tabs to spaces converter expand each tab?
Leading and inline tabs both expand this way so visual alignment is preserved.
What if my text has no tab characters?
You can skip writing the file back when changed is false.
Why did a tab turn into three spaces instead of four?
Because a tab is a jump to the next tab stop, not a fixed run of spaces. With a width of 4, a tab sitting after one character only needs three spaces to reach that stop, so that is what it gets.
What tab width should I choose?
Use the width your editor or style guide expects. The default is 4, the usual setting in most modern editors, and any whole number from 1 to 16 works, so a two-space style is covered too.
Do Windows line endings survive the conversion?
Yes. Carriage returns are kept as they are and the column count only restarts at the line feed, so files with Windows line endings come out with the same endings and correctly aligned columns.
Related tools
For developers — API access
Everything on this page is available programmatically. This section is for teams who want to wire it into their own systems; everyone else can just use the tool above.
API endpoint
Prefer to automate it? One authenticated POST creates the task; the result comes back by webhook or a signed link. The same capability also runs here on the web, by email and from Telegram — and soon from our app too.
Call it from your stack
curl -X POST https://api.kit.forhosting.com/dev/tabs-to-spaces \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"function hi() {\n\treturn true;\n}"}'const res = await fetch("https://api.kit.forhosting.com/dev/tabs-to-spaces", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"text": "function hi() {\n\treturn true;\n}"
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/dev/tabs-to-spaces",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"text": "function hi() {\n\treturn true;\n}"
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/dev/tabs-to-spaces", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"text":"function hi() {\\n\\treturn true;\\n}"}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"text":"function hi() {\n\treturn true;\n}"}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/dev/tabs-to-spaces", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"text": "function hi() {\n\treturn true;\n}"
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "dev.tabs_to_spaces",
"status": "queued",
"_links": {
"result": "/tasks/tsk_…/result"
}
}The API is asynchronous: the call returns a task_id immediately and the result arrives by webhook. Polling is capped at 1 req/s per task.
Pricing
Published price — no tokens, no invented credits. A failed task is never charged.
Errors
| HTTP | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or invalid API key. |
402 | insufficient_balance | Your balance doesn't cover the task price. |
404 | unknown_type | That task type doesn't exist. |
429 | rate_limited | Too many requests. Use the webhook instead of polling. |
Technical notes
How tab stops expand tabs into spaces without losing alignment
This converter implements that same rule: it keeps a zero-based column index that resets at every line feed, increments by one for each ordinary character (including ordinary spaces), and when it sees a tab it computes n equals tab_size minus column modulo tab_size, then emits exactly n spaces and advances the column by n. That is why a tab at column zero with tab_size four becomes four spaces, while a tab at column one becomes three spaces, and a tab already sitting on a stop becomes a full new stop of four spaces again. The algorithm is a single left-to-right pass with no lookahead, no language parsing, and no network calls, so the same input always produces the same output on every Worker and in the browser.
Input fields, response shape, limits, and validation errors
POST a JSON body with a required string field text holding the source or plain text you want to normalize. Optional aliases such as input, code, source, content, and body are accepted for the same payload so existing form posts and scripts need less renaming. Optional integer tab_size sets the tab stop width; the default is four, matching most modern editor defaults and the common JavaScript, Python, and Rust style guides that expect four-space or two-space indents once tabs are gone. Valid values are integers from one through sixteen inclusive; values outside that range, non-integers, NaN, and Infinity fail with invalid_input naming tab_size. Missing text, a non-string text, or an empty string fail with invalid_input and are not billed. The maximum input length is one hundred thousand characters so a single call stays within Worker memory budgets for typical source files and small multi-file snippets. On success the response includes text, the fully expanded string; tab_size, the width that was applied; tabs_replaced, how many tab characters were found; spaces_added, the total number of space characters inserted in their place; chars_before and chars_after, the lengths before and after conversion; line_count, the number of lines after splitting on common newline forms; changed, a boolean true when at least one tab was expanded; and message, a short human summary of the rewrite. If the input had no tabs, text is returned unchanged, tabs_replaced is zero, changed is false, and the message states that nothing was altered so callers can short-circuit writes.
When a tabs to spaces API earns its keep in real workflows
Migration scripts that bulk-convert a legacy repository from tabs to spaces can call the same contract from a shell loop or from an edge Worker without installing Node modules on every laptop. Keep requests under the character limit, pin tab_size in your config, and you get reproducible space-only source for CI, LMS plugins, and offline batches without standing up a full language-aware formatter for a character-level indent fix.
Integration use cases
CI style normalization
Expand hard tabs to spaces with a fixed tab_size before lint and format steps so diffs stay clean and tools that reject mixed indentation pass.
Legacy repo bulk rewrite
Batch-convert tab-indented modules to pure spaces via the API while preserving mid-line alignment tabs as column-correct space runs.
Technical questions
What values can I use for tab_size?
Any integer from 1 to 16 inclusive. The default is 4. Values outside that range or non-integers fail with invalid_input and are not billed.
Are existing spaces removed or collapsed?
No. Existing space characters are left unchanged. Only U+0009 tab characters are replaced with the space run required by the current column and tab_size.
What does the tabs to spaces converter cost?
The browser tool is free. API requests use prepaid KIT balance at $0.002 per successful call; invalid input is not billed.