Subtitle files look trivial until one breaks. A video player shows nothing, captions appear two seconds late, or an upload form rejects a file that "looks fine". Almost every one of these problems comes down to a handful of rules about how SRT and WebVTT files are put together.
This guide walks through both formats line by line, explains when to use which, covers the readability rules professional subtitlers follow, and gives you short Python functions to convert, clean and re-time files.
Quick comparison
| Feature | SRT (SubRip) | VTT (WebVTT) |
|---|---|---|
| Header | None | WEBVTT on the first line |
| Millisecond separator | Comma: 00:00:01,000 | Period: 00:00:01.000 |
| Cue numbers | Required by convention | Optional cue identifiers |
| Hours in timestamp | Always present | Optional (01:02.500 is valid) |
| Styling | Unofficial b, i, u, font tags | CSS via STYLE blocks and ::cue |
| Positioning | Not standardized | Cue settings: line, position, align, size |
| Comments | No | NOTE blocks |
| Speaker labels | Plain text only | Voice tags such as v Alice |
| HTML5 track element | Not supported | The required format |
| Formal specification | No, de facto standard | Yes, W3C |
The short version: SRT is the most widely accepted interchange format, VTT is the web format. Keep one master file and convert as needed.
Anatomy of an SRT file
SRT comes from SubRip, a Windows tool for ripping subtitles from DVDs. It was never formally specified, which is why it's so forgiving and also why edge cases vary between players. A file is a series of blocks separated by blank lines:
1
00:00:01,000 --> 00:00:04,200
Welcome back to the show.
2
00:00:04,400 --> 00:00:07,900
Today we're talking about
how subtitle files work.
Each block has four parts:
- A sequence number. Counts up from 1. Many players ignore it, but some validators reject files where it's missing or out of order.
- A timing line. Start time, the arrow
-->with spaces around it, end time. The format isHH:MM:SS,mmmwith a comma before the milliseconds. - One or more lines of text. Usually one or two.
- A blank line that ends the block. A missing blank line is one of the most common reasons a cue silently swallows the next one.
Many players also render a small set of HTML-like tags: <i>, <b>, <u> and <font color="...">. Support is inconsistent, so treat these as optional hints and keep the text readable without them.
Anatomy of a WebVTT file
WebVTT (Web Video Text Tracks) is a W3C format designed for HTML5 video. It looks like SRT on purpose, with a few important differences:
WEBVTT
Kind: captions
Language: en
NOTE This file was exported from the edit on 23 October.
STYLE
::cue(.speaker) {
color: yellow;
}
intro
00:00:01.000 --> 00:00:04.200
<v Host>Welcome back to the show.
00:00:04.400 --> 00:00:07.900 line:85% align:center
<v Guest>Today we're talking about
how subtitle files work.
What's new compared with SRT:
- The WEBVTT header. It must be the first thing in the file (after an optional byte order mark). Without it, browsers reject the file.
- Periods, not commas, before milliseconds. The hours field is optional, so
01:04.500is valid. - Optional cue identifiers like
introinstead of required numbers. You can still use numbers. - Cue settings after the end time:
line(vertical position),position(horizontal),align,size(cue box width) andvertical(for vertical text). Use them to move captions out of the way of on-screen text. - NOTE blocks for comments, which players ignore.
- STYLE blocks with CSS using the
::cuepseudo-element. Browser support for styling varies, so test it. - Voice tags like
<v Host>that mark who's speaking, plus class spans and inline timestamps for karaoke-style highlighting.
To use a VTT file on a web page, attach it with a track element and serve it with the text/vtt content type. If the file lives on another domain, the server also has to send the right CORS headers, and the video element needs a crossorigin attribute.
<video controls crossorigin="anonymous" src="talk.mp4">
<track kind="captions" src="talk.en.vtt" srclang="en" label="English" default>
</video>
Other formats you'll run into
| Format | Where you see it | Notes |
|---|---|---|
| TTML / DFXP | Broadcast, streaming delivery specs | W3C XML format. DFXP is the older profile name, IMSC is the profile most streaming services ask for. |
| SCC | US broadcast television | Encodes the old line-21 caption data. Hard to edit by hand, use a dedicated tool. |
| SBV | Older video platform exports | Like SRT without numbers. The start and end times are separated by a comma on one line. |
| ASS / SSA | Anime fansubs, karaoke, Aegisub | Rich styling, fonts and animation. Converting to SRT or VTT loses most of it. |
If someone asks for "captions" without naming a format, deliver SRT and VTT. Between them they cover nearly every platform, editor and player.
Readability rules that actually matter
A technically valid file can still be unreadable. Professional style guides converge on a few numbers. These are approximate summaries, so check the current guide for any delivery that has to pass a spec.
| Rule | Common target | Source of the convention |
|---|---|---|
| Characters per line | About 42 | Netflix English style guide |
| Characters per line (broadcast) | About 37 | BBC guidance, from the teletext era |
| Lines per cue | 2 maximum | Nearly every guide |
| Reading speed | Up to about 20 characters per second for adults | Netflix English, lower for children's content |
| Reading speed (words) | Roughly 160 to 180 words per minute | BBC guidance |
| Minimum duration | About 5/6 of a second | Netflix, so short cues don't flash |
| Maximum duration | About 7 seconds | Netflix, so cues don't linger |
| Gap between cues | A couple of frames | Lets viewers see that a new cue started |
A few more habits make a big difference:
- Break lines at natural phrase boundaries. Keep an article with its noun and a verb with its subject. "Today we're talking about / how subtitle files work" reads better than "Today we're talking about how / subtitle files work".
- Prefer a bottom-heavy shape when two lines are uneven, so the eye drops into the longer line.
- Don't cross shot changes if you can avoid it. A cue that spans a cut feels late.
- Mark speakers when it isn't obvious who's talking: a voice tag in VTT, or a leading dash or name in SRT.
- For captions (not subtitles), include meaningful sounds like [door slams] or [music fades]. This is what accessibility standards expect. Our captions accessibility guide covers the rules.
Convert SRT to VTT (and back) in Python
Because the formats are so close, conversion is mostly text cleanup. These functions use only the standard library.
import re
TIME = re.compile(r"(\d{1,2}:)?(\d{2}):(\d{2})[,.](\d{3})")
def normalize(text: str) -> str:
# Strip a UTF-8 byte order mark and Windows line endings
return text.lstrip("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
def srt_to_vtt(srt: str) -> str:
text = normalize(srt).strip()
# SRT puts a comma before milliseconds, VTT uses a period
text = re.sub(r"(\d{2}:\d{2}:\d{2}),(\d{3})", r"\1.\2", text)
return "WEBVTT\n\n" + text + "\n"
def vtt_to_srt(vtt: str) -> str:
blocks = re.split(r"\n\s*\n", normalize(vtt).strip())
out, n = [], 1
for block in blocks:
lines = block.split("\n")
# Find the timing line, which skips the header, NOTE and STYLE blocks
idx = next((i for i, l in enumerate(lines) if "-->" in l), None)
if idx is None:
continue
start, end = [t.strip().split(" ")[0] for t in lines[idx].split("-->")]
start, end = (pad_hours(start).replace(".", ","), pad_hours(end).replace(".", ","))
body = [re.sub(r"<[^>]+>", "", l) for l in lines[idx + 1:]]
out.append(f"{n}\n{start} --> {end}\n" + "\n".join(body))
n += 1
return "\n\n".join(out) + "\n"
def pad_hours(t: str) -> str:
# VTT allows MM:SS.mmm, SRT always needs HH:MM:SS,mmm
return t if t.count(":") == 2 else "00:" + t
Two details worth noticing. The VTT to SRT direction drops cue settings (everything after the end time) and strips tags, because SRT has no standard way to represent them. And it renumbers cues, since VTT identifiers can be words.
Turn a subtitle file into plain text
For reading, quoting, search or feeding an LLM, you usually want the words without the timing scaffolding.
def subtitles_to_text(sub: str) -> str:
lines, last = [], None
for line in normalize(sub).split("\n"):
line = line.strip()
if not line or line == "WEBVTT" or line.isdigit() or "-->" in line:
continue
if line.startswith(("NOTE", "STYLE", "Kind:", "Language:")):
continue
line = re.sub(r"<[^>]+>", "", line)
# Auto-generated captions often repeat the previous line
if line != last:
lines.append(line)
last = line
return " ".join(lines)
This is deliberately simple. It won't skip the CSS inside a STYLE block line by line, so if your files use styling, drop whole blocks before the loop. If you need paragraphs, start a new one when the gap between cues is longer than a second or two, which usually lines up with a pause or a change of topic. If you'd rather not write code, our SRT to text converter and timestamp remover do the same thing in the browser.
Shift and rescale timestamps
There are two kinds of sync problems, and they need different fixes:
- Constant offset. Every cue is late or early by the same amount, typically because an intro was trimmed or added. Add or subtract a fixed number of milliseconds.
- Progressive drift. Captions are fine at the start and drift further out over time. The subtitles were timed against a different frame rate, such as 25 fps against 23.976 fps. Multiply every time by the ratio instead of adding an offset.
def to_ms(t: str) -> int:
m = TIME.fullmatch(t.strip())
h = int(m.group(1)[:-1]) if m.group(1) else 0
return ((h * 60 + int(m.group(2))) * 60 + int(m.group(3))) * 1000 + int(m.group(4))
def from_ms(ms: int, sep: str) -> str:
ms = max(0, ms) # never emit negative times
h, rem = divmod(ms, 3_600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02}:{m:02}:{s:02}{sep}{ms:03}"
def retime(sub: str, offset_ms: int = 0, scale: float = 1.0, sep: str = ",") -> str:
def fix(match):
return from_ms(round(to_ms(match.group(0)) * scale) + offset_ms, sep)
return TIME.sub(fix, sub)
# Captions 1.5 seconds late in an SRT file
# retime(srt, offset_ms=-1500)
# Timed at 25 fps, video is 23.976 fps
# retime(srt, scale=25 / 23.976)
Use sep="," for SRT and sep="." for VTT. To work out the scale factor without knowing the frame rates, measure a cue near the start and one near the end against the video and solve for the ratio.
Common bugs that break players
| Symptom | Likely cause | Fix |
|---|---|---|
| VTT file shows nothing in the browser | Missing WEBVTT header, wrong content type or a CORS block | Add the header, serve as text/vtt, add CORS headers and crossorigin |
| First cue missing or garbled | A byte order mark before the header or the first number | Strip the BOM and re-save as UTF-8 |
| Accented characters show as junk | File saved in Windows-1252 or another legacy encoding | Detect the encoding and re-save as UTF-8 |
| Two cues merge into one | Missing blank line between blocks | Make sure every block ends with an empty line |
| Cues flicker or stack | Overlapping start and end times | Set each end time before the next start time |
| Upload rejected as invalid SRT | Periods instead of commas, or missing sequence numbers | Use HH:MM:SS,mmm and number every block from 1 |
| Captions vanish after an edit | Negative times after shifting | Clamp to zero or drop cues before the new start |
Overlaps are worth checking automatically. Sort cues by start time and flag any whose start is earlier than the previous cue's end. Some platforms render overlaps as stacked lines, which is occasionally what you want for two speakers, but usually it's a timing mistake.
A sensible workflow
- Keep one master file, usually SRT or VTT, in UTF-8 with LF line endings.
- Generate other formats on export rather than editing several files by hand.
- Validate before you ship: check headers, separators, overlaps and duration limits in a script, then watch a few minutes in a real player.
- Version your subtitles alongside the video edit, because any re-cut changes the timing.
If your starting point is a recording rather than a subtitle file, you'll need speech-to-text first. Our speech-to-text API guide covers how to choose an engine and get word-level timestamps you can turn into cues.
Frequently asked questions
What is the difference between SRT and VTT?+
Both are plain-text subtitle formats with numbered or named cues and start and end times. SRT uses a comma before milliseconds and has no header or styling standard. WebVTT starts with a WEBVTT header, uses a period before milliseconds, and supports positioning, styling, comments and speaker tags. Browsers' HTML5 track element only supports VTT.
Should I use SRT or VTT?+
Use VTT for anything played in a web browser with the HTML5 video element. Use SRT when uploading to social platforms, video editors or desktop players, because it is the most widely accepted format. Most platforms accept both, so keep one master file and convert.
Can I just rename an .srt file to .vtt?+
No. A VTT file needs the WEBVTT header on the first line and periods instead of commas in timestamps. Some lenient players will cope with a renamed file, but browsers will usually reject it or show nothing.
Why are my subtitles out of sync?+
If they are off by the same amount the whole way through, add or subtract a fixed offset. If they drift further apart over time, the subtitles were timed against a different frame rate (for example 25 fps versus 23.976 fps) and need to be rescaled, not shifted.
How many characters should a subtitle line have?+
A common rule is about 42 characters per line and no more than two lines, which is what Netflix specifies for English. Broadcast guidelines such as the BBC's have traditionally worked with shorter lines, around 37 characters.
How do I turn a subtitle file into plain text?+
Remove the WEBVTT header, cue numbers, timestamp lines and any tags, then join the remaining lines into paragraphs. Auto-generated captions often repeat lines, so remove consecutive duplicates as well.