A KISAM file is KCML's native keyed-ISAM data file: fixed-length records indexed by one or more key paths. This example puts the whole lifecycle on a single tabbed form — the first tab creates a KISAM file and writes records to it from input fields (one field per column), and the second tab reads every record back into a grid. For a true end-to-end picture, the page finishes by reading the very same file from a short Python script — KISAM records are just fixed-length bytes, so any language can read them.
![The Write tab: ID, Name and City input fields mapping to record columns, an Add record button, and a status line reading 'Wrote record [00004] Dan Brook, Oxford (4 records total)'.](images/kisam-write.png)

Verified by execution on KCML 06.00.88 (KClient direct mode).
KI_CREATE.KI_WRITE and reading them all back with KI_START_BEG + KI_READ_NEXT.struct — no KCML required.The whole example — save it as demo_kisam.src and launch it with the GUI
client in direct mode (kclient -d demo_kisam.src). Adjust file$ to a writable
path on your machine; the directory must already exist. The annotated walkthrough
of the key parts follows below.
01000 REM demo_kisam - create a KISAM file, write records from input fields, read them back
: REM Tab 1 writes records (one field per column); Tab 2 reads them all into a grid.
: DIM result, ki_status, handle, ki_sym, ki_dataptr$6, ki_key$64
: DIM keyspec$(1)33, nrows, reclen, ftype, dictfile$2, file$120, rec$41
: DIM recs_written, cnt, gr, sv_i, msg$120
: DIM in_id$5, in_name$20, in_city$15
: DIM seed_id$(3)5, seed_name$(3)20, seed_city$(3)15
: file$ = "C:/kisam_data/PEOPLE"
: nrows = 200
: reclen = 41
: ftype = 6
: dictfile$ = " "
: seed_id$(1) = "00001" : seed_name$(1) = "Alice Walker" : seed_city$(1) = "London"
: seed_id$(2) = "00002" : seed_name$(2) = "Bob Stone" : seed_city$(2) = "Leeds"
: seed_id$(3) = "00003" : seed_name$(3) = "Carol Nash" : seed_city$(3) = "Bristol"
01010 - DEFFORM KisamDemo()=\
{.form,.form$,.Style=0x50c000c4,.Width=440,.Height=320,.Text$="KISAM - create, write and read",.Id=1024},\
{.tabMain,.tabbed$,.Style=0x500100c0,.Left=8,.Top=8,.Width=424,.Height=270,.Id=1000,\
.TabWrite={.Text$="1. Write"},\
.TabRead={.Text$="2. Read"}},\
{.lblHint,.static$,.Style=0x50000000,.Left=20,.Top=44,.Width=395,.Height=22,.Text$="Each field below maps to a column in the record. Type values and click Add.",.Id=2000,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.lblId,.static$,.Style=0x50000000,.Left=20,.Top=78,.Width=70,.Height=12,.Text$="ID (key):",.Id=2001,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.edtId,.edit$,.Style=0x50810080,.Left=95,.Top=76,.Width=90,.Height=13,.Id=2002,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.lblName,.static$,.Style=0x50000000,.Left=20,.Top=98,.Width=70,.Height=12,.Text$="Name:",.Id=2003,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.edtName,.edit$,.Style=0x50810080,.Left=95,.Top=96,.Width=200,.Height=13,.Id=2004,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.lblCity,.static$,.Style=0x50000000,.Left=20,.Top=118,.Width=70,.Height=12,.Text$="City:",.Id=2005,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.edtCity,.edit$,.Style=0x50810080,.Left=95,.Top=116,.Width=150,.Height=13,.Id=2006,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.btnAdd,.button$,.Style=0x50010000,.Left=95,.Top=140,.Width=110,.Height=15,.Text$="Add record",.Id=2007,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.lblWrote,.static$,.Style=0x50000000,.Left=20,.Top=170,.Width=395,.Height=40,.Text$="",.Id=2008,.Parent=.tabMain,.Page=.TabWrite,.Font=.UI},\
{.lblReadHint,.static$,.Style=0x50000000,.Left=20,.Top=44,.Width=395,.Height=12,.Text$="Every record read back from the KISAM file:",.Id=3000,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
{.grid,.KCMLgrid$,.Style=0x50013030,.Left=20,.Top=60,.Width=395,.Height=170,.Id=3001,.Rows=21,.Cols=3,.FixedRows=1,.Parent=.tabMain,.Page=.TabRead,.Font=.Mono},\
{.btnLoad,.button$,.Style=0x50010000,.Left=20,.Top=236,.Width=130,.Height=15,.Text$="Reload from file",.Id=3002,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
{.lblCount,.static$,.Style=0x50000000,.Left=160,.Top=238,.Width=255,.Height=12,.Text$="",.Id=3003,.Parent=.tabMain,.Page=.TabRead,.Font=.UI},\
{.btnClose,.button$,.Style=0x50010001,.Left=352,.Top=283,.Width=80,.Height=15,.Text$="Close",.Id=1,.Font=.UI},\
{.paneStatus,.status$,.Width=440,.Style=0x50000000,.Text$="Ready"},\
{.UI,.dlgfont$,.Name$="Segoe UI",.Size=10},\
{.Mono,.dlgfont$,.Name$="Consolas",.Size=10},\
{.clrHdr,.color$,.Red=224,.Green=224,.Blue=224}
: + DEFEVENT KisamDemo.Enter()
: keyspec$(1) = ALL(HEX(00))
: STR(keyspec$(1), 1, 1) = "N"
: STR(keyspec$(1), 2, 2) = BIN(2, 2)
: STR(keyspec$(1), 4, 1) = BIN(5)
: CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
: CALL KI_CREATE handle, file$, dictfile$, nrows, reclen, SYM(keyspec$()), 4, 50, ftype TO ki_status
: recs_written = 0
: FOR sv_i = 1 TO 3
: rec$ = ALL(HEX(20))
: STR(rec$, 1, 1) = HEX(00)
: STR(rec$, 2, 5) = seed_id$(sv_i)
: STR(rec$, 7, 20) = seed_name$(sv_i)
: STR(rec$, 27, 15) = seed_city$(sv_i)
: ki_sym = SYM(rec$)
: CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
: IF ki_status == 0 THEN recs_written = recs_written + 1
: NEXT sv_i
: CALL KI_CLOSE handle TO ki_status
: .edtId.Text$ = "00004"
: .edtName.Text$ = "Dan Brook"
: .edtCity.Text$ = "Oxford"
: .lblWrote.Text$ = $PRINTF("Created the file and seeded %d records. Add more, then open the Read tab.", recs_written)
: .paneStatus.Text$ = $PRINTF("KISAM file created - %d records", recs_written)
: END EVENT
: + DEFEVENT KisamDemo.btnAdd.Click()
: in_id$ = RTRIM(.edtId.Text$)
: in_name$ = RTRIM(.edtName.Text$)
: in_city$ = RTRIM(.edtCity.Text$)
: CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
: CALL KI_OPEN handle, file$, "W" TO ki_status
: rec$ = ALL(HEX(20))
: STR(rec$, 1, 1) = HEX(00)
: STR(rec$, 2, 5) = in_id$
: STR(rec$, 7, 20) = in_name$
: STR(rec$, 27, 15) = in_city$
: ki_sym = SYM(rec$)
: CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
: CALL KI_CLOSE handle TO ki_status
: IF ki_status == 0 THEN recs_written = recs_written + 1
: IF ki_status == 0 THEN .lblWrote.Text$ = $PRINTF("Wrote record [%s] %s, %s (%d records total).", in_id$, in_name$, in_city$, recs_written)
: IF ki_status <> 0 THEN .lblWrote.Text$ = $PRINTF("Write failed - status %d (duplicate key?)", ki_status)
: .paneStatus.Text$ = $PRINTF("btnAdd.Click - write status %d", ki_status)
: END EVENT
: + DEFEVENT KisamDemo.tabMain.TabRead.Enter()
: GOSUB 2000
: END EVENT
: + DEFEVENT KisamDemo.btnLoad.Click()
: GOSUB 2000
: END EVENT
: FORM END KisamDemo
01020 result = KisamDemo.Open()
: $END
02000 REM load_grid subroutine - read every record into the grid
: .grid.Cell(0,1).ColWidth = 70
: .grid.Cell(0,2).ColWidth = 200
: .grid.Cell(0,3).ColWidth = 120
: .grid.Cell(1,1).Text$ = "ID"
: .grid.Cell(1,2).Text$ = "Name"
: .grid.Cell(1,3).Text$ = "City"
: FOR gr = 1 TO 3
: .grid.Cell(1,gr).BackColor = &.clrHdr
: NEXT gr
: FOR gr = 2 TO 20
: .grid.Cell(gr,1).Text$ = ""
: .grid.Cell(gr,2).Text$ = ""
: .grid.Cell(gr,3).Text$ = ""
: NEXT gr
: CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
: CALL KI_OPEN handle, file$, "R" TO ki_status
: CALL KI_START_BEG handle, 1 TO ki_status
: cnt = 0
: gr = 2
: rec$ = ALL(HEX(20))
: ki_sym = SYM(rec$)
: REPEAT
: CALL KI_READ_NEXT handle, 1, ki_sym TO ki_status, ki_dataptr$, ki_key$
: IF ki_status == 0 THEN DO
: .grid.Cell(gr,1).Text$ = RTRIM(STR(rec$, 2, 5))
: .grid.Cell(gr,2).Text$ = RTRIM(STR(rec$, 7, 20))
: .grid.Cell(gr,3).Text$ = RTRIM(STR(rec$, 27, 15))
: cnt = cnt + 1
: gr = gr + 1
: END DO
: UNTIL ki_status <> 0 OR gr > 20
: CALL KI_CLOSE handle TO ki_status
: .lblCount.Text$ = $PRINTF("%d records loaded", cnt)
: .paneStatus.Text$ = $PRINTF("load_grid - %d records", cnt)
: RETURN
Each record is a fixed 41-byte string. Byte 1 is reserved by KISAM; the columns start at byte 2:
| Bytes | Column | Notes |
|---|---|---|
| 1 | (status) | KISAM control byte — HEX(00) = live record |
| 2–6 | id |
the key (5 chars) |
| 7–26 | name |
20 chars |
| 27–41 | city |
15 chars |
The key specification is a 33-byte string in an array, built byte-by-byte: a
duplicates flag ("N" = no duplicates) followed by one 4-byte segment
(2-byte start, 1-byte length, 1-byte reserved). Here the key is the 5-byte id
starting at byte 2:
: DIM keyspec$(1)33, dictfile$2, nrows, reclen, ftype
: keyspec$(1) = ALL(HEX(00))
: STR(keyspec$(1), 1, 1) = "N" : REM no duplicate keys
: STR(keyspec$(1), 2, 2) = BIN(2, 2) : REM key starts at byte 2
: STR(keyspec$(1), 4, 1) = BIN(5) : REM key is 5 bytes long
: nrows = 200 : reclen = 41 : ftype = 6 : dictfile$ = " "
: CALL KI_ALLOC_HANDLE 0, 1 TO handle, ki_status
: CALL KI_CREATE handle, file$, dictfile$, nrows, reclen, SYM(keyspec$()), 4, 50, ftype TO ki_status
KI_CREATE both creates and opens the file. Its argument order in KCML 6.x
is handle, file$, dictfile$, maxrows, rowlen, SYM(keyspec$()), blocklen, packing, type.
dictfile$ (a data-dictionary name) is unused — pass a single space. blocklen
is the index block size in 256-byte units (4) and packing is the index packing
percentage (50).
The Add button copies the three input fields into their byte ranges and calls
KI_WRITE. Byte 1 is left as the status byte (KI_WRITE forces it to HEX(00)):
: CALL KI_OPEN handle, file$, "W" TO ki_status
: rec$ = ALL(HEX(20)) : REM space-fill the record
: STR(rec$, 1, 1) = HEX(00) : REM status byte
: STR(rec$, 2, 5) = RTRIM(.edtId.Text$)
: STR(rec$, 7, 20) = RTRIM(.edtName.Text$)
: STR(rec$, 27, 15)= RTRIM(.edtCity.Text$)
: ki_sym = SYM(rec$)
: CALL KI_WRITE handle, ki_sym, 0 TO ki_status, ki_dataptr$
: CALL KI_CLOSE handle TO ki_status
The third KI_WRITE argument (0) selects key path 1. A status of 0 means the
record was inserted; a non-zero status on a fresh key usually means a duplicate
key.
The Read tab opens the file read-only, positions at the start of key path 1 with
KI_START_BEG, then loops KI_READ_NEXT until it returns non-zero (end of file).
Each record is sliced back into columns by the same byte offsets and dropped into
a grid row:
: CALL KI_OPEN handle, file$, "R" TO ki_status
: CALL KI_START_BEG handle, 1 TO ki_status
: gr = 2 : REM grid data starts at row 2
: rec$ = ALL(HEX(20))
: ki_sym = SYM(rec$)
: REPEAT
: CALL KI_READ_NEXT handle, 1, ki_sym TO ki_status, ki_dataptr$, ki_key$
: IF ki_status == 0 THEN DO
: .grid.Cell(gr,1).Text$ = RTRIM(STR(rec$, 2, 5))
: .grid.Cell(gr,2).Text$ = RTRIM(STR(rec$, 7, 20))
: .grid.Cell(gr,3).Text$ = RTRIM(STR(rec$, 27, 15))
: gr = gr + 1
: END DO
: UNTIL ki_status <> 0 OR gr > 20
: CALL KI_CLOSE handle TO ki_status
Pre-assign ki_sym = SYM(rec$) before the loop — do not pass SYM(rec$)
inline to KI_READ_NEXT. KI_READ_NEXT also returns the record's raw key in
ki_key$ and its row pointer in ki_dataptr$ (which must be 6 bytes).
Because a KISAM record is just fixed-length bytes at known offsets, any
language can read the file KCML wrote — no KCML required. The script below opens
PEOPLE, reads the record length straight out of the type-6 header, finds the
data records, and slices each field out by byte position. Run it after the form
has created the file and you get the same three rows back:
file : PEOPLE (17920 bytes)
header : magic='K6' reclen=41
records: 3 found
id name city
--------------------------------------------
00001 Alice Walker London
00002 Bob Stone Leeds
00003 Carol Nash Bristol
#!/usr/bin/env python3
# Reads the KISAM file produced by demo_kisam.src — without using KCML at all.
#
# A type-6 KISAM file is split into fixed-size blocks: some hold the index (the
# sorted keys, each with a pointer to a record), others hold the data — the
# records themselves, laid out back-to-back. We just want the data records.
#
# Record layout (must match the KCML program that wrote it). KCML strings are
# fixed-length and space-padded, so every record is exactly 41 bytes. Byte 1 is
# a control byte KISAM manages; the real columns start at byte 2:
#
# offset(1-based) length column notes
# 1 1 status 0x00 = a live record
# 2 5 id the key, ASCII, space-padded
# 7 20 name ASCII, space-padded
# 27 15 city ASCII, space-padded
import struct # struct: unpack fixed-width binary fields
import sys
from pathlib import Path
# demo_kisam.src writes to <repo>/example_code/forms/kisam_data/PEOPLE.
HERE = Path(__file__).resolve().parent
KISAM_FILE = HERE.parent / "forms" / "kisam_data" / "PEOPLE"
# The record layout as (column-name, 1-based-start, length) — the offsets read
# exactly like the KCML STR(rec$, start, len) calls that wrote the fields.
FIELDS = [
("id", 2, 5),
("name", 7, 20),
("city", 27, 15),
]
def read_header(buf):
"""Pull the two things we need out of the KISAM header.
A type-6 header begins with the ASCII magic "K6" (bytes 0-1) and stores the
record length as a 2-byte BIG-ENDIAN integer at bytes 4-5. We read the length
from the file rather than hard-coding 41, so the reader survives layout
changes. ">H" = big-endian (>) unsigned 16-bit (H).
"""
magic = buf[0:2].decode("ascii", "replace") # -> "K6"
(reclen,) = struct.unpack_from(">H", buf, 4) # read 2 bytes at offset 4
return magic, reclen
def looks_like_record(buf, off, reclen):
"""True if the bytes at `off` look like a real, live data record.
The index block holds the same key values (e.g. "00001") but followed by
4-byte row pointers, not name/city text — so we must not mistake those for
records. A genuine record has a 0x00 status byte and printable ASCII in the
key and name (an index entry's "name" area is pointer bytes / nulls).
"""
if off + reclen > len(buf):
return False
if buf[off] != 0x00: # byte 1 must be the live flag
return False
key = buf[off + 1:off + 6] # bytes 2-6 (id)
name = buf[off + 6:off + 26] # bytes 7-26 (name)
printable = lambda bs: all(0x20 <= b <= 0x7E for b in bs)
return printable(key) and key[0:1] != b" " and printable(name)
def find_records(buf, reclen):
"""Yield each record as a `reclen`-byte slice.
Walk the file until we hit something that looks like a record; since records
sit back-to-back in a data block, jump forward by `reclen` to the next one.
When a slot stops looking like a record (we've run past the last one), go
back to scanning byte-by-byte. No need to know where the data block starts.
"""
off = 0
while off + reclen <= len(buf):
if looks_like_record(buf, off, reclen):
yield buf[off:off + reclen]
off += reclen
else:
off += 1
def decode_record(rec):
"""Turn one raw record into {column: text}. Each field is a fixed-width
ASCII slice; KCML pads on the right with spaces, so we strip them off."""
out = {}
for name, start, length in FIELDS:
raw = rec[start - 1:start - 1 + length] # 1-based offset -> 0-based slice
out[name] = raw.decode("ascii", "replace").rstrip()
return out
def main():
if not KISAM_FILE.exists():
sys.exit(f"KISAM file not found: {KISAM_FILE} (run demo_kisam.src first)")
buf = KISAM_FILE.read_bytes()
magic, reclen = read_header(buf)
print(f"file : {KISAM_FILE.name} ({len(buf)} bytes)")
print(f"header : magic={magic!r} reclen={reclen}")
if magic != "K6":
sys.exit("not a type-6 KISAM file (magic != 'K6')")
records = [decode_record(r) for r in find_records(buf, reclen)]
print(f"records: {len(records)} found\n")
print(f"{'id':<7}{'name':<22}{'city'}")
print("-" * 44)
for r in records:
print(f"{r['id']:<7}{r['name']:<22}{r['city']}")
if __name__ == "__main__":
main()
The fields here are all text, so decoding is just "slice and strip". KISAM
records can of course hold binary integers, packed-decimal numbers, dates and
more — the companion script read_kisam_types.py in the repo decodes a record
containing one field of every KCML data type.
KI_WRITE overwrites its first character with HEX(00).KI_CREATE argument order is exact. Supplying the wrong number or order of
arguments raises S24.057 — Stack frame problem, possibly wrong parameters
supplied to user function. The keyspec must be passed as SYM(keyspec$()) (the
symbol of the array), not as a plain string.CREATE TABLE ... TYPE 7
through KI_PREPARE/KI_EXECUTE needs a configured KDB database and tablespace;
in a plain direct-mode session that is unavailable (KI_PREPARE returns status
45). KI_CREATE builds a standalone type-6 file with no database required.GOSUB'd subroutine keeps the form context. The Read tab calls a shared
load_grid subroutine via GOSUB, and the dotted .grid references resolve
correctly there — the current-form context carries across the GOSUB.