- Section
- Bash
- Updated
- 24 Aug 2026
- Examples
- 17
When processing large datasets in shell scripts, native
Bash associative arrays (declare -A) quickly hit memory and iteration bottlenecks. Invoking external tools like Python, awk, or sqlite3 inside a
loop solves the data structure problem, but pays the penalty of fork() and exec() on every invocation.
The Bash shell provides a native mechanism to load compiled C shared libraries directly into the shell process memory: Bash loadable builtins via the enable -f command.
[me@linux ~]$ enable -f ./libfast_kv.so fast_kv
[me@linux ~]$ type -t fast_kv
builtinLoaded builtins run in the same process space as the shell. They execute without subshell creation, pipe IPC, or process context switching, while allowing off-heap memory management for high-cardinality data.
What is the enable command in Bash?
The enable shell builtin is primarily used to display, enable, or disable built-in shell commands. For example, enable -n echo disables the internal echo builtin so Bash falls back to the /bin/echo binary on disk.
When given the -f flag, enable acts as a dynamic library loader (using dlopen() internally). It opens a compiled shared library file (.so), locates the builtin definition structure, and registers the command into the shell’s active symbol table.
[me@linux ~]$ enable -f /path/to/plugin.so mycommandTo view all builtins currently enabled in your session, run enable with no arguments:
[me@linux ~]$ enable
enable .
enable :
enable alias
enable bg
enable bind
enable break
enable builtin
...Structure of a C loadable builtin
A Bash loadable builtin is a C source file implementing four components:
- Standard Bash header includes (
builtins.h,shell.h,common.h). - An entry point function with the signature
int command_builtin(WORD_LIST *list). - A documentation string array used by the
helpbuiltin. - A
struct builtindescriptor exporting the command name and function pointer.
Here is a minimal C builtin that stores and retrieves integer values by key using an in-process static table:
/* fast_kv.c - Minimal Bash loadable builtin */
#include <config.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "builtins.h"
#include "shell.h"
#include "bashgetopt.h"
#include "common.h"
/* Simple in-memory entry */
struct kv_entry {
char key[64];
long value;
struct kv_entry *next;
};
static struct kv_entry *head = NULL;
int fast_kv_builtin(WORD_LIST *list) {
if (list == NULL) {
builtin_usage();
return (EX_USAGE);
}
char *action = list->word->word;
list = list->next;
if (strcmp(action, "set") == 0) {
if (!list || !list->next) {
builtin_error("usage: fast_kv set <key> <value>");
return (EXECUTION_FAILURE);
}
char *key = list->word->word;
long val = atol(list->next->word->word);
/* Update existing or prepend new */
struct kv_entry *e = head;
while (e) {
if (strcmp(e->key, key) == 0) {
e->value = val;
return (EXECUTION_SUCCESS);
}
e = e->next;
}
struct kv_entry *new_entry = malloc(sizeof(struct kv_entry));
strncpy(new_entry->key, key, sizeof(new_entry->key) - 1);
new_entry->key[sizeof(new_entry->key) - 1] = '\0';
new_entry->value = val;
new_entry->next = head;
head = new_entry;
return (EXECUTION_SUCCESS);
}
if (strcmp(action, "get") == 0) {
if (!list) {
builtin_error("usage: fast_kv get <key>");
return (EXECUTION_FAILURE);
}
char *key = list->word->word;
struct kv_entry *e = head;
while (e) {
if (strcmp(e->key, key) == 0) {
printf("%ld\n", e->value);
return (EXECUTION_SUCCESS);
}
e = e->next;
}
return (EXECUTION_FAILURE);
}
builtin_error("unknown action '%s': use set or get", action);
return (EX_USAGE);
}
/* Documentation displayed by the help builtin */
char *fast_kv_doc[] = {
"Store and retrieve key-value pairs in shell memory.",
"",
"Usage: fast_kv set <key> <value>",
" fast_kv get <key>",
"",
"Manipulates in-memory key-value data directly without subshells.",
(char *)NULL
};
/* Exported builtin descriptor */
struct builtin fast_kv_struct = {
"fast_kv", /* Builtin command name */
fast_kv_builtin, /* Function pointer */
BUILTIN_ENABLED, /* Initial status */
fast_kv_doc, /* Documentation strings */
"fast_kv set|get <key> [val]", /* Short usage synopsis */
0 /* Reserved */
};How to compile a Bash loadable builtin
To compile the shared object, you need the Bash C development headers. On Debian and Ubuntu systems, install bash-builtins:
[me@linux ~]# apt-get install bash-builtinsOn Fedora, RHEL, or AlmaLinux, install bash-devel:
[me@linux ~]# dnf install bash-develCompile the source with -fPIC (Position Independent Code) and -shared:
[me@linux ~]$ gcc -fPIC -shared -I/usr/include/bash -I/usr/include/bash/include -I/usr/include/bash/builtins -o libfast_kv.so fast_kv.cIf compiling on macOS with Homebrew Bash headers, use clang with -undefined dynamic_lookup so the dynamic linker resolves host shell symbols at runtime:
[me@mac ~]$ clang -shared -fPIC -undefined dynamic_lookup -I/opt/homebrew/include/bash -I/opt/homebrew/include/bash/include -I/opt/homebrew/include/bash/builtins -o libfast_kv.so fast_kv.cIf your Linux distribution does not ship split packages, you can compile against the headers of the GNU Bash source tree corresponding to your version:
[me@linux ~]$ gcc -fPIC -shared -I/path/to/bash-source -I/path/to/bash-source/include -I/path/to/bash-source/builtins -o libfast_kv.so fast_kv.c👉 The full
fast_kv.csource, plus akv_trie.cvariant backed by libexpanse and aMakefile, are in a GitHub gist. Clone it and runmaketo build in one step.
Loading and testing the builtin
Once compiled, load the .so file into your interactive shell or script using enable -f:
[me@linux ~]$ enable -f ./libfast_kv.so fast_kvVerify that Bash recognizes the new command as a builtin using the type command:
[me@linux ~]$ type fast_kv
fast_kv is a shell builtin
[me@linux ~]$ type -t fast_kv
builtinThe documentation registered in fast_kv_doc is immediately accessible through help:
[me@linux ~]$ help fast_kv
fast_kv: fast_kv set|get <key> [val]
Store and retrieve key-value pairs in shell memory.
Usage: fast_kv set <key> <value>
fast_kv get <key>
Manipulates in-memory key-value data directly without subshells.Execute the command like any other shell builtin:
[me@linux ~]$ fast_kv set worker_threads 8
[me@linux ~]$ fast_kv set queue_timeout 300
[me@linux ~]$ fast_kv get worker_threads
8
[me@linux ~]$ fast_kv get queue_timeout
300
[me@linux ~]$ if fast_kv get missing_key >/dev/null; then echo "Found"; else echo "Not found"; fi
Not foundOff-heap data structures and scaling limits
When automating high-volume workflows in shell scripts, standard patterns hit severe scaling walls:
- Subshell fork/exec overhead: Calling an external helper binary or database client (
sqlite3,cut, Python) inside a shell loop pays process creation costs on every iteration. Executing 10,000 lookups via external command expansion takes 22.18 seconds. - Simple builtin linear search (
fast_kv): A custom C loadable builtin eliminates subshell overhead, running 10,000 lookups in 0.21 seconds. However, becausefast_kvuses a simple linked list (O(N) per-lookup linear traversal), pushing it to 20,000 keys causes lookups to stall at 22.72 seconds. - Native Bash arrays (
declare -A): Associative arrays use internal hash tables for O(1) amortized lookups, but each string key and value is stored as an individual heap struct. At 100,000 keys, the Bash process consumes 12.6 MB of RAM, and performing 500 range queries across 10,000 items takes 18.34 seconds because hash tables cannot perform ordered scans without dumping keys and piping tosort.
| Approach | Point lookups (10k) | Full scan (20k keys) | RSS (100k keys) | Range scans (500 × 10k) |
|---|---|---|---|---|
External command in loop $(...) | 22.18 s | — | — | — |
fast_kv (C linked-list builtin) | 0.21 s | 22.72 s | ~8.0 MB | — |
Native Bash declare -A | 0.15 s | 0.21 s | 12.6 MB | 18.34 s |
kv_trie (Expanse trie builtin) | < 0.01 s | 0.05 s | 0.35 MB | < 0.01 s |
💡 Bold marks the best result in each column; a dash marks a workload the approach cannot do natively. Neither the external loop nor the linked-list
fast_kvcan scan an ordered range without dumping and sorting every key first, and the fork-per-item loop was not profiled for the 20k-key traversal or resident memory. On complexity,fast_kvis O(N) per lookup (O(N²) to scan the full set) anddeclare -Ais O(1) but unordered, whereaskv_triestays O(depth) — bounded by the 8-byte key width — for both lookups and ordered ranges, holding 100k keys in 0.35 MB (~36× less RAM thandeclare -A).
Because loadable builtins run arbitrary C code, you can replace the simple linked list with a true off-heap digital trie.
libexpanse is a modernized take on Judy arrays — the same cache-friendly, memory-efficient ordered trie, rebuilt to be memory-safe with a native C API and prebuilt Debian and RPM packages. Install the runtime and headers:
# Debian/Ubuntu
[me@linux ~]# echo "deb [trusted=yes] https://orieg.github.io/expanse/apt/ stable main" | tee /etc/apt/sources.list.d/expanse.list
[me@linux ~]# apt-get update
[me@linux ~]# apt-get install -y libexpanse1 libexpanse-dev# Fedora/RHEL/AlmaLinux
[me@linux ~]# dnf config-manager --add-repo https://orieg.github.io/expanse/rpm/expanse.repo
[me@linux ~]# dnf install -y libexpanse libexpanse-develInclude <expanse.h>, store into an expanse_map_t (an ordered uint64_t to uint64_t map), and link with -lexpanse:
[me@linux ~]$ gcc -fPIC -shared -I/usr/include/bash -I/usr/include/bash/include -I/usr/include/bash/builtins -o libkv_trie.so kv_trie.c -lexpanseA complete kv_trie.c — the fast_kv example rewritten to store keys in an expanse_map_t through libexpanse’s native C API — is in the
same gist. Your builtin then stores keys in an off-heap trie inside the shell process, keeping the Bash memory footprint compact (0.35 MB for 100k keys) while eliminating the overhead of spawning external database clients or helper binaries on every lookup.
How to unload a loadable builtin
To remove a loaded builtin from the running shell, use the -d option with enable:
[me@linux ~]$ enable -d fast_kv
[me@linux ~]$ type fast_kv
bash: type: fast_kv: not found⚠️ ABI Compatibility & Process Safety
- Bash Version Matching: A loadable builtin must be compiled against headers matching the exact major and minor version of the running Bash shell. Loading a module compiled for Bash 5.1 into Bash 5.2 can cause undefined behavior or memory corruption if internal struct layouts changed.
- Process Lifetime: A segmentation fault or memory leak in a loadable builtin will terminate or degrade the parent Bash process itself, rather than failing an isolated subshell. Test builtins thoroughly before loading them into mission-critical automation scripts.
