The SEG-Y format is the Society of Exploration Geophysicists' standard for exchanging seismic data. A file starts with a 3200-byte textual header (EBCDIC or ASCII) and a 400-byte binary header, then optional 3200-byte extended headers, then traces: a 240-byte trace header plus samples. Format code 1 means IBM floats; code 5 means IEEE floats.
Reading the samples correctly is the hard part: byte order rules changed in 2017, format code 1 is an IBM System/360 float, and processing shops have moved trace header fields around since 1975. We wrote this reference while building our own SEG-Y parser and WebGL seismic viewer. Byte positions come from the SEG standards, and every code sample was run against known values. Files usually end in .sgy or .segy.
What are the SEG-Y revisions, and which one is my file?
SEG has published four revisions: rev 0 (1975), rev 1 (May 2002), rev 2.0 (January 2017) and rev 2.1 (October 2023). Binary header bytes 3501 and 3502 record the revision the writer claimed. Zero means rev 0.
| Revision | Published | What changed for a reader |
|---|---|---|
| Rev 0 | 1975, Geophysics vol. 40 | The 3200-, 400- and 240-byte headers and four sample formats, including IBM float. Big-endian. Trace header bytes 181 to 240 and binary header bytes 3261 to 3600 unassigned. |
| Rev 1 | May 2002 | IEEE float (code 5), 1-byte integers (code 8), extended textual headers, ASCII text, and standard slots for CDP X and Y (181 to 188), inline (189) and crossline (193). Still big-endian. |
| Rev 2.0 | January 2017 | Little-endian and pairwise byte-swapped files, IEEE double, 24-bit, 64-bit and unsigned integers, up to 65,535 extra trace headers per trace, 64-bit trace counts, data trailers. |
| Rev 2.1 | October 2023 | A survey type field at 3509 to 3510, carved out of the old 3507 to 3510 field, and deprecated appendices D-3 to D-5 removed. |
Rev 1 stores the revision as one 16-bit word with the radix point between the bytes, so rev 1.0 is 0x0100. Rev 2 splits it into two 8-bit values, so rev 2.1 is 0x02 then 0x01. Read two single bytes and every revision decodes correctly, little-endian files included.
The rev 2.1 standard says SEG-Y was not designed for field recording and points to SEG-D or SEG-2, so raw shot records may arrive in those.
How is a SEG-Y file laid out on disk?
On disk, SEG-Y is a flat byte stream with no record markers. Bytes 1 to 3200 are the textual header (40 lines of 80 characters) and bytes 3201 to 3600 the binary header. Then come zero or more 3200-byte extended textual headers (count in bytes 3505 to 3506), the trace records back to back, and in rev 2 optional 3200-byte trailer records (count in bytes 3529 to 3532).
Rev 2 also defines an optional 128-byte tape label, and Appendix A warns that one may have been copied to the front of a disk file. That is the likely cause of a textual header shifted by 128 bytes.
The textual header is free-form. By convention each line starts with C (0xC3 in EBCDIC, 0x43 in ASCII), but not every file follows it, and extended header lines deliberately don't. Counting blanks is a sturdier test: the header is mostly spaces, 0x40 in EBCDIC and 0x20 in ASCII. Python's cp037 code page decodes the EBCDIC. Rev 2.1 asks for the revision on line 39 (SEG-Y_REV2.1) and END TEXTUAL HEADER on line 40. In old files these lines are often the only record of processing history, coordinate system and header locations.
SEG numbers bytes from 1, counting binary header bytes from the start of the file, so byte 3225 is offset 24 in the 400-byte block and trace byte 115 is offset 114. Off-by-one errors here still produce believable numbers.
Which binary header fields does a reader actually need?
A reader needs the format code (3225 to 3226), samples per trace (3221 to 3222), sample interval (3217 to 3218), byte order constant (3297 to 3300), revision (3501 to 3502), fixed-length flag (3503 to 3504) and extended header count (3505 to 3506). Rev 2 adds wider fields that override the 16-bit ones when nonzero; trust them only when the revision byte is 2 or higher, since older files may hold anything there.
| Bytes | Size | Field | Notes |
|---|---|---|---|
| 3217 to 3218 | 2 | Sample interval | µs for time, Hz for frequency, m or ft for depth |
| 3221 to 3222 | 2 | Samples per data trace | For the primary traces in the file |
| 3225 to 3226 | 2 | Data sample format code | See the next section |
| 3255 to 3256 | 2 | Measurement system | 1 = meters, 2 = feet |
| 3269 to 3272 | 4 | Extended samples per trace | Rev 2; overrides 3221 to 3222 |
| 3273 to 3280 | 8 | Extended sample interval | Rev 2; IEEE double; overrides 3217 to 3218. 3281 to 3292 hold the same pair for the original field recording. |
| 3297 to 3300 | 4 | Byte order constant | Rev 2; 16909060 (0x01020304) |
| 3501 | 1 | Major revision | Rev 1 used 3501 to 3502 as one word |
| 3502 | 1 | Minor revision | 0x01 for rev 2.1 |
| 3503 to 3504 | 2 | Fixed length trace flag | 1 = all traces the same length |
| 3505 to 3506 | 2 | Extended textual header count | -1 = variable, ends with ((SEG: EndText)) |
| 3507 to 3508 | 2 | Max additional trace headers | Rev 2.0 used 3507 to 3510 |
| 3513 to 3520 | 8 | Number of traces | Unsigned 64-bit |
| 3521 to 3528 | 8 | Byte offset of first trace | Overrides the offset implied by 3505 to 3506 |
The standard requires big-endian for rev 0 and rev 1. In rev 2, bytes 3297 to 3300 hold the integer 16909060. If a big-endian read returns 0x04030201, reverse the bytes of every binary header, trace header and sample field; if it returns 0x02010403, swap consecutive byte pairs. Zero tells you to assume the pre-rev 2 big-endian order. Textual headers and trailers are never swapped. Non-conforming writers exist, so cross-check with the format code: code 1 read with the wrong byte order comes out as 256, and code 5 as 1280.
What do the SEG-Y data sample format codes mean?
The two-byte code at binary header bytes 3225 to 3226 sets the type and width of every sample in the file. Code 1 is 4-byte IBM floating point, code 5 is 4-byte IEEE floating point, code 4 is obsolete, and the rest are integers or doubles.
| Code | Sample type | Bytes |
|---|---|---|
| 1 | IBM floating point | 4 |
| 2 | Two's complement integer | 4 |
| 3 | Two's complement integer | 2 |
| 4 | Fixed point with gain (obsolete) | 4 |
| 5 | IEEE floating point | 4 |
| 6 | IEEE floating point | 8 |
| 7 | Two's complement integer | 3 |
| 8 | Two's complement integer | 1 |
| 9 | Two's complement integer | 8 |
| 10 | Unsigned integer | 4 |
| 11 | Unsigned integer | 2 |
| 12 | Unsigned integer | 8 |
| 15 | Unsigned integer | 3 |
| 16 | Unsigned integer | 1 |
Rev 0 defined codes 1 to 4, rev 1 added 5 and 8, and rev 2.0 added the rest. Codes 13 and 14 are unassigned, and a 6 or 7 in a rev 1 file means damage or a private extension.
A trace record is the 240-byte header, 240 bytes per rev 2 extension header, then the samples. With the fixed-length flag at 1, trace k starts at the first-trace offset plus k record lengths. At 0 you walk the file, taking the sample count from trace bytes 115 to 116. If Trace Header Extension 1 is present, its bytes 137 to 140 override that count and bytes 157 to 158 give the number of extension blocks.
Expect code 1 in older archives. Matt Hall at Agile Scientific quoted a contact in 2021 who said 90% of the SEG-Y he saw in the wild still used IBM floats. That is one person's sample.
How do you convert IBM floating point to IEEE 754?
An IBM single is a sign bit, a 7-bit base-16 exponent biased by 64, and a 24-bit fraction with the radix point on its left, so the value is (-1)^s × 0.F × 16^(e - 64). Decode into float64, which holds every IBM single exactly, and cast to float32 afterward if you need to.
Take 0xC276A000. The sign bit is 1. The exponent bits are 0x42 (66), giving 16^2 = 256. The fraction is 0x76A000 / 2^24 = 0.46337890625. The product is 118.625, so the value is -118.625. That matches Wikipedia's worked example for IBM hexadecimal floating point and the layout in Appendix E of the rev 2.1 standard.
import numpy as np
def ibm_to_float(raw, byteorder=">"):
"""Decode 4-byte IBM System/360 floats (SEG-Y format code 1) to float64."""
words = np.frombuffer(raw, dtype=np.dtype(np.uint32).newbyteorder(byteorder))
sign = np.where(words >> 31, -1.0, 1.0)
exponent = ((words >> 24) & 0x7F).astype(np.int64) - 64 # excess-64, base 16
fraction = (words & 0x00FFFFFF).astype(np.float64) / 2**24 # 0.F, radix point on the left
return sign * np.ldexp(fraction, 4 * exponent) # 16**e == 2**(4e)
For a browser viewer reading an ArrayBuffer, the JavaScript version is shorter:
export function ibmToFloat(view, byteOffset, littleEndian = false) {
const w = view.getUint32(byteOffset, littleEndian);
const sign = w >>> 31 ? -1 : 1;
const exponent = ((w >>> 24) & 0x7f) - 64;
const fraction = (w & 0x00ffffff) / 0x1000000;
return sign * fraction * Math.pow(16, exponent);
}
Both return -118.625 for 0xC276A000, 100.0 for 0x42640000, 1.0 for 0x41100000 and 0.00390625 for 0x3F100000. Each also matched an exact rational-arithmetic reference on 200,000 random 32-bit words, a test worth repeating on any faster bit-twiddling version. Other traps:
- The IBM range is much wider than float32. The largest IBM single is about 7.2 × 10^75 and the smallest normalized one about 5.4 × 10^-79, while IEEE single precision tops out near 3.4 × 10^38. Cast to float32 and 0x7FFFFFFF becomes infinity; one corrupt sample like that ruins an auto-scaled color bar.
- Precision wobbles. A base-16 exponent lets a normalized fraction start with up to three zero bits, leaving as few as 21 significant bits. IBM to float32 is exact inside float32's normal range, but IEEE back to IBM can lose up to three low-order bits.
- The IBM format has no NaN or infinity. Every bit pattern is a number, so garbage decodes quietly.
- Rev 1's Appendix E drew the fraction as 23 bits with the last bit forced to zero. Rev 2.1 shows 24. Decode all 24.
- A wrong format code is hard to see in the numbers. Read 0x42640000 as IEEE and you get 57.0 instead of 100.0; read 0xC276A000 as IEEE and you get -61.65625 instead of -118.625.
Which trace header fields matter, and where do files actually put them?
For 3D post-stack data, the fields that matter are inline (189 to 192), crossline (193 to 196), CDP X and Y (181 to 188) with the coordinate scalar (71 to 72), samples per trace (115 to 116) and sample interval (117 to 118). Rev 0 left bytes 181 to 240 unassigned, so older files often keep inline and crossline numbers somewhere else.
Need Custom Software?
Get a free 30-minute architecture review with our team. 12+ years building enterprise applications.
| Bytes | Field |
|---|---|
| 5 to 8 | Trace sequence number within file |
| 21 to 24 | Ensemble number (CDP, CMP, CRP) |
| 29 to 30 | Trace identification code (1 = time-domain seismic, 2 = dead) |
| 71 to 72 | Scalar for coordinates in bytes 73 to 88 and 181 to 188 |
| 73 to 88 | Source X and Y, then group X and Y |
| 89 to 90 | Coordinate units (1 = length, 2 = arc seconds, 3 = decimal degrees, 4 = DMS) |
| 109 to 110 | Delay recording time |
| 115 to 116 | Number of samples in this trace |
| 117 to 118 | Sample interval for this trace |
| 181 to 188 | CDP X and Y |
| 189 to 192 | Inline number (3D post-stack) |
| 193 to 196 | Crossline number (3D post-stack) |
| 215 to 216 | Scalar for times in bytes 95 to 114 |
These positions are from Table 3 of the rev 2.1 standard. Real files depart from it in predictable places:
- Inline and crossline move the most. In the same Agile Scientific post, Hall lists 189 and 193 as recommended, then "1 and 5 (well, 5 or 9). Or somewhere else." He reports that Don Robinson at Resolve had seen more than 180 byte-location combinations, and another company more than 300. Equinor's segyio defaults to 189 and 193 and accepts
ilineandxlinearguments for files that differ. - Look for a layout before you guess. Rev 2 can embed an XML trace header layout as a
((SEG:Layout))extended header stanza, and Appendix D-8 of rev 2.1 says the same XML can sit beside a rev 0 file to record where inline and crossline live. Failing that, read the textual header. - When you have to guess, test candidate fields in pairs. Skip the sequence counters at bytes 1 to 8, which step on every trace. In an inline-sorted volume, the crossline candidate should step regularly and reset each time the inline candidate increments, both ranges should match the survey grid, and the CDP X and Y values should move by one bin spacing per step.
- Coordinates are integers with a scalar. Bytes 71 to 72 hold 1, ±10, ±100, ±1000 or ±10,000; positive multiplies, negative divides, zero means 1. Ignore a scalar of -100 and every coordinate comes out a hundred times too large. If bytes 89 to 90 say 2 or 3, X is longitude and Y is latitude.
- A nonzero delay in bytes 109 to 110 shifts the trace in time, and it can be negative (allowed since rev 1). Apply the time scalar in bytes 215 to 216 before you use it.
- The standard contradicts itself in one spot. Its binary header table and Table 4 put the extended sample count of Trace Header Extension 1 at bytes 137 to 140, while the Appendix A text says 129 to 132, which is actually the extended offset field. Use 137 to 140.
How do you read SEG-Y traces in Python?
If you only need the data, use a library such as segyio with the inline and crossline bytes you found. A reader of your own parses the file header, picks the byte order, finds the first trace, then walks trace records by fixed length or by each trace's sample count.
This code handles format codes 1 and 5, using ibm_to_float from above. It raises on pairwise-swapped files and on a variable extended header count, which both need a scan.
import struct
import numpy as np
BYTES_PER_SAMPLE = {1: 4, 2: 4, 3: 2, 4: 4, 5: 4, 6: 8, 7: 3, 8: 1,
9: 8, 10: 4, 11: 2, 12: 8, 15: 3, 16: 1}
def read_file_header(f, label=0):
f.seek(label) # label=128 if a tape label was copied in front
text, binhdr = f.read(3200), f.read(400)
# A heuristic, not a rule: textual headers are mostly blanks (0x40 EBCDIC, 0x20 ASCII)
encoding = "cp037" if text.count(0x40) > text.count(0x20) else "ascii"
rev = binhdr[300] # byte 3501 (rev 1 stores 0x0100 across 3501-3502)
magic = binhdr[96:100] # bytes 3297-3300, unassigned before rev 2
if rev < 2 or magic in (b"\x01\x02\x03\x04", b"\x00\x00\x00\x00"):
bo = ">"
elif magic == b"\x04\x03\x02\x01":
bo = "<"
else:
raise ValueError(f"pairwise byte-swapped or unknown byte order: {magic.hex()}")
get = lambda fmt, byte: struct.unpack_from(bo + fmt, binhdr, byte - 3201)[0]
h = {"text": text.decode(encoding, errors="replace"), "byteorder": bo,
"revision": (rev, binhdr[301]), "format": get("H", 3225),
"sample_interval": get("H", 3217), "samples": get("H", 3221),
"fixed_length": get("H", 3503) if rev >= 1 else 0,
"n_ext_text": get("h", 3505) if rev >= 1 else 0,
"extra_headers": 0, "n_traces": 0, "first_trace": 0}
if rev >= 2: # rev 2 fields override the 16-bit ones when nonzero
h["samples"] = get("I", 3269) or h["samples"]
h["sample_interval"] = get("d", 3273) or h["sample_interval"]
h["extra_headers"] = get("H", 3507)
h["n_traces"] = get("Q", 3513)
h["first_trace"] = get("Q", 3521) # counted from the start of the file
if not h["first_trace"]:
if h["n_ext_text"] < 0:
raise ValueError("variable extended header count: scan for ((SEG: EndText))")
h["first_trace"] = label + 3600 + 3200 * h["n_ext_text"]
return h
def read_traces(f, h):
"""Yield (trace_header, samples) for format codes 1 and 5."""
bo, width = h["byteorder"], BYTES_PER_SAMPLE[h["format"]]
f.seek(h["first_trace"])
count = 0
while not h["n_traces"] or count < h["n_traces"]:
th = f.read(240)
if len(th) < 240:
return
n = struct.unpack_from(bo + "H", th, 114)[0] # trace bytes 115-116
if h["extra_headers"]:
ext = f.read(240)
n_extra = h["extra_headers"]
if ext[232:240] in (b"SEG00001", "SEG00001".encode("cp037")): # Extension 1
n_extra = struct.unpack_from(bo + "H", ext, 156)[0] or n_extra # bytes 157-158
n = struct.unpack_from(bo + "I", ext, 136)[0] or n # bytes 137-140
f.seek(240 * (n_extra - 1), 1)
if h["fixed_length"]:
n = h["samples"]
raw = f.read(n * width)
if h["format"] == 1:
yield th, ibm_to_float(raw, bo)
elif h["format"] == 5:
yield th, np.frombuffer(raw, dtype=bo + "f4")
else:
raise NotImplementedError(f"format code {h['format']}")
count += 1
We tested it on synthetic files only: big- and little-endian rev 2.1 IBM files, a variable-length file with two extension headers per trace, a rev 1 file with junk where rev 2 fields now live, a rev 0 file, an EBCDIC header with blank-led lines, and a file behind a 128-byte label.
Should you convert SEG-Y to OpenVDS or ZGY for cloud and web viewing?
For an interactive working copy in the cloud or a browser, usually yes. VDS and ZGY store bricks, so slices in any direction read quickly; SEG-Y stores whole traces and reads fast only along its sort order. Keep the original SEG-Y as the archive of record.
A time slice from an inline-sorted volume needs a few bytes from every trace. dGB Earth Sciences, which added OpenVDS and OpenZGY support to OpendTect Pro 7, describes SEG-Y as fast inline, slower crossline and slower again for Z slices, and both brick formats as fast in all directions. A browser reading from object storage pays for that in many small range requests or in bytes it throws away.
OpenVDS comes from Bluware and is published through the OSDU program. Its documentation lists four compression options (none, run-length, Zip and wavelet), a default brick of 128 × 128 × 128 samples (64 cubed is recommended for uncompressed data), and levels of detail that halve each axis, up to LOD12. The open-source OpenVDS, licensed Apache 2.0, cannot create wavelet-compressed VDS; the free but closed-source OpenVDS+ can. With the original headers stored and lossless amplitude compression, VDS can export a bitwise-identical SEG-Y file; lossy compression gives that up. ZGY is SLB's Petrel format, available as OpenZGY through OSDU.
The SEG-Y visualization project decodes IBM floats in a custom parser and draws about 1.2 million points in about 80 ms with WebGL. For DepthSync we wrote a custom Rust OpenVDS decompressor to support real-time collaborative seismic editing. Our post on 3D seismic data visualization covers the rendering side.
Where should you start with a legacy SEG-Y archive?
Start with an inventory script that reads only file headers. Problems usually surface as disagreements between the binary header, the textual header and the file size.
- For every file, record the format code, byte order constant, revision bytes, extended header count, samples per trace, sample interval and file size.
- For fixed-length files, check that file size equals first-trace offset plus traces times record length, plus any trailer. A remainder points to a tape label, variable traces or a wrong sample count.
- Print the textual header and compare its claims about coordinates, units and header locations with the binary fields.
- Find where inline, crossline and coordinates actually live, and record the scalars.
- Decode a few traces as both IBM and IEEE and plot them; the 57.0 example shows why numbers alone can fool you.
- Write the findings to a manifest per file, leave the originals untouched, and convert a working copy to VDS or ZGY using the byte locations from the manifest.
Our guide to legacy system integration and reverse engineering applies the same spec-then-files method to other formats, and the posts on Wonderware and OSIsoft PI data extraction and refinery control system modernization cover plant data. Our oil and gas page lists related work. If a SEG-Y archive is blocking a cloud or web project, send us a few sample headers.