Beginner

Variables and Types in Common Lisp

Learn about variables, bindings, and the rich type system in Common Lisp - from dynamic typing to symbols, numbers, strings, and sequences

Variables in Common Lisp work differently from most languages you may have encountered. As a dynamically typed, multi-paradigm Lisp, variables are untyped containers — the values carry their types, not the variable names. This means the same variable can hold an integer, then a string, then a list, all at runtime. The type system is strong (no implicit coercions between incompatible types) but dynamic (no compile-time type declarations required).

Common Lisp distinguishes between lexical and dynamic (special) variables, a distinction that gives the language unusual expressive power. Lexical variables follow familiar scoping rules; dynamic variables use a runtime binding stack that enables powerful patterns like thread-local state and dynamic context. Understanding this distinction is key to reading and writing idiomatic Common Lisp.

In this tutorial you will explore variable declaration, lexical vs. dynamic binding, Common Lisp’s core types (numbers, strings, characters, booleans, symbols, lists, and vectors), type inspection, and type conversion.


Lexical Variables with let

The primary way to introduce local variables is with let. All bindings created by let are established simultaneously before the body executes — they cannot reference each other. Use let* when each binding needs to see the previous one.

Create a file named variables_basic.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
;;; Variables and Bindings in Common Lisp

;; let introduces lexical variables
(let ((x 42)
      (name "Common Lisp")
      (pi-approx 3.14159))
  (format t "x = ~a~%" x)
  (format t "name = ~a~%" name)
  (format t "pi ≈ ~a~%" pi-approx))

;; let* allows sequential bindings (each sees the previous)
(let* ((base 10)
       (height 5)
       (area (* base height)))
  (format t "base = ~a, height = ~a, area = ~a~%" base height area))

;; Variables can hold any type - they are untyped containers
(let ((value 100))
  (format t "value is ~a, type: ~a~%" value (type-of value))
  (setf value "now a string")
  (format t "value is ~a, type: ~a~%" value (type-of value))
  (setf value '(1 2 3))
  (format t "value is ~a, type: ~a~%" value (type-of value)))

Running with Docker

1
2
3
4
5
# Pull the SBCL image
docker pull clfoundation/sbcl:latest

# Run the basic variables example
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script variables_basic.lisp

Expected Output

x = 42
name = Common Lisp
pi ≈ 3.14159
base = 10, height = 5, area = 50
value is 100, type: (INTEGER 0 4611686018427387903)
value is now a string, type: (SIMPLE-ARRAY CHARACTER (13))
value is (1 2 3), type: CONS

Note: SBCL’s type-of returns very precise types. The exact form for integers and strings may vary slightly by platform and value range.


Global and Dynamic Variables

Global variables in Common Lisp are conventionally named with earmuffs — asterisks on each side, like *counter*. This naming convention signals that the variable is intended to be dynamically bound, not lexical.

defvar and defparameter create top-level (global) variables. defvar only sets the value on the first load; defparameter always resets it. defconstant defines a compile-time constant.

Create a file named variables_global.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
;;; Global variables: defvar, defparameter, defconstant

;; defvar: sets value only if the variable is unbound
(defvar *app-name* "CodeArchaeology")
(defvar *version* "1.0.0")

;; defparameter: always sets the value (useful for reloadable configs)
(defparameter *debug-mode* nil)
(defparameter *max-retries* 3)

;; defconstant: compile-time constant (value must not change)
(defconstant +golden-ratio+ 1.6180339887)
(defconstant +speed-of-light+ 299792458)  ; metres per second

(format t "App: ~a v~a~%" *app-name* *version*)
(format t "Debug mode: ~a~%" *debug-mode*)
(format t "Max retries: ~a~%" *max-retries*)
(format t "Golden ratio: ~a~%" +golden-ratio+)
(format t "Speed of light: ~a m/s~%" +speed-of-light+)

;; Dynamic binding with let overrides a special variable locally
;; The original value is restored when let exits
(defparameter *greeting* "Hello")

(defun print-greeting (name)
  (format t "~a, ~a!~%" *greeting* name))

(print-greeting "World")

(let ((*greeting* "Greetings"))   ; dynamically rebind *greeting*
  (print-greeting "Lisper"))      ; uses "Greetings" inside this let

(print-greeting "World")          ; restored to "Hello"

Running with Docker

1
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script variables_global.lisp

Expected Output

App: CodeArchaeology v1.0.0
Debug mode: NIL
Max retries: 3
Golden ratio: 1.6180340051651
Speed of light: 299792458 m/s
Hello, World!
Greetings, Lisper!
Hello, World!

Note: Floating point constants may display with slight precision differences due to IEEE 754 representation.


Numbers: Integers, Floats, Rationals, and Complex

Common Lisp has an unusually rich numeric tower. Integers are arbitrary precision by default (no overflow). Rational numbers are exact fractions. Floating point comes in single and double precision. Complex numbers are built in.

Create a file named variables_numbers.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
;;; The Common Lisp Numeric Tower

;; Integers: arbitrary precision, no overflow
(let ((big (* 1000000000000 1000000000000)))
  (format t "Big integer: ~a~%" big)
  (format t "Type: ~a~%" (type-of big)))

;; Rationals: exact fractions
(let ((one-third (/ 1 3))
      (two-fifths (/ 2 5)))
  (format t "1/3 = ~a~%" one-third)
  (format t "2/5 = ~a~%" two-fifths)
  (format t "1/3 + 2/5 = ~a~%" (+ one-third two-fifths))
  (format t "Type of 1/3: ~a~%" (type-of one-third)))

;; Floats: single and double precision
(let ((sf 3.14)          ; single-float (default on most implementations)
      (df 3.14d0))       ; double-float (d0 suffix)
  (format t "Single float: ~a, type: ~a~%" sf (type-of sf))
  (format t "Double float: ~a, type: ~a~%" df (type-of df)))

;; Complex numbers
(let ((c1 #c(3 4))       ; complex with integer parts
      (c2 #c(1.0 2.0)))  ; complex with float parts
  (format t "Complex: ~a, magnitude: ~,4f~%" c1 (abs c1))
  (format t "Complex: ~a~%" c2)
  (format t "Real part: ~a, Imaginary part: ~a~%" (realpart c1) (imagpart c1)))

;; Type predicates
(format t "~%Type checks:~%")
(format t "42 is integer? ~a~%" (integerp 42))
(format t "3.14 is float? ~a~%" (floatp 3.14))
(format t "1/3 is rational? ~a~%" (rationalp 1/3))
(format t "42 is number? ~a~%" (numberp 42))

Running with Docker

1
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script variables_numbers.lisp

Expected Output

Big integer: 1000000000000000000000000
Type: (INTEGER 4611686018427387904 *)
1/3 = 1/3
2/5 = 2/5
1/3 + 2/5 = 11/15
Type of 1/3: RATIO
Single float: 3.14, type: SINGLE-FLOAT
Double float: 3.14d0, type: DOUBLE-FLOAT
Complex: #C(3 4), magnitude: 5.0000
Complex: #C(1.0 2.0)
Real part: 3, Imaginary part: 4

Type checks:
42 is integer? T
3.14 is float? T
1/3 is rational? T
42 is number? T

Strings, Characters, and Symbols

Strings in Common Lisp are mutable sequences of characters (though treating them as immutable is good practice). Characters are their own distinct type, written with a #\ prefix. Symbols are the most distinctive Lisp type — they are interned names used for identifiers, keywords, and as data.

Create a file named variables_types.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
;;; Strings, Characters, Symbols, and Booleans

;; Strings: mutable character sequences
(let ((greeting "Hello, Common Lisp!")
      (empty ""))
  (format t "String: ~a~%" greeting)
  (format t "Length: ~a~%" (length greeting))
  (format t "Uppercase: ~a~%" (string-upcase greeting))
  (format t "Substring: ~a~%" (subseq greeting 7 18))
  (format t "Empty? ~a~%" (string= empty "")))

;; Characters: distinct type, written as #\X
(let ((ch-a #\A)
      (ch-space #\Space)
      (ch-newline #\Newline))
  (format t "~%Character: ~a~%" ch-a)
  (format t "Char code: ~a~%" (char-code ch-a))
  (format t "Is alpha? ~a~%" (alpha-char-p ch-a))
  (format t "Is space? ~a~%" (char= ch-space #\Space))
  ;; Convert between strings and characters
  (format t "String from char: ~a~%" (string ch-a))
  (format t "First char of string: ~a~%" (char "Alpha" 0)))

;; Symbols: interned names, a uniquely Lisp concept
;; Symbols are used as identifiers, keys, and data
(let ((sym 'hello)
      (keyword :status))
  (format t "~%Symbol: ~a~%" sym)
  (format t "Symbol type: ~a~%" (type-of sym))
  (format t "Keyword: ~a~%" keyword)
  (format t "Keyword type: ~a~%" (type-of keyword))
  ;; Symbols have names
  (format t "Symbol name: ~a~%" (symbol-name sym))
  ;; Keywords are self-evaluating and used as named parameters
  (format t "Is keyword? ~a~%" (keywordp keyword)))

;; Booleans: T and NIL
;; NIL is also the empty list; T is the canonical true value
;; Any non-NIL value is truthy
(let ((yes t)
      (no nil)
      (also-true 42)     ; any non-NIL value is true
      (also-false '()))  ; empty list is NIL
  (format t "~%T is true? ~a~%" (if yes "yes" "no"))
  (format t "NIL is false? ~a~%" (if no "yes" "no"))
  (format t "42 is truthy? ~a~%" (if also-true "yes" "no"))
  (format t "() is falsy? ~a~%" (if also-false "yes" "no"))
  (format t "NIL = ()? ~a~%" (eq nil '())))

Running with Docker

1
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script variables_types.lisp

Expected Output

String: Hello, Common Lisp!
Length: 19
Uppercase: HELLO, COMMON LISP!
Substring: Common Lisp
Empty? T

Character: A
Char code: 65
Is alpha? T
Is space? T
String from char: A
First char of string: A

Symbol: HELLO
Symbol type: SYMBOL
Keyword: :STATUS
Keyword type: KEYWORD
Symbol name: HELLO
Is keyword? T

T is true? yes
NIL is false? no
42 is truthy? yes
() is falsy? no
NIL = ()? T

Type Conversion

Common Lisp’s strong type system means conversions are always explicit. The language provides a comprehensive set of coercion and conversion functions.

Create a file named variables_conversion.lisp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
;;; Type Conversion in Common Lisp

;; Number conversions
(let ((n 42)
      (f 3.7)
      (r 7/2))
  ;; Integer to float
  (format t "42 as float: ~a~%" (float n))
  ;; Float to integer (truncate, floor, ceiling, round)
  (format t "3.7 truncated: ~a~%" (truncate f))
  (format t "3.7 floored: ~a~%" (floor f))
  (format t "3.7 ceiling: ~a~%" (ceiling f))
  (format t "3.7 rounded: ~a~%" (round f))
  ;; Rational to float
  (format t "7/2 as float: ~a~%" (float r))
  ;; Float to rational (exact!)
  (format t "3.14 as rational: ~a~%" (rationalize 3.14)))

;; String / number conversions
(format t "~%String/Number conversions:~%")
(let ((num-str "42")
      (float-str "3.14"))
  ;; String to number
  (format t "\"42\" as number: ~a~%" (parse-integer num-str))
  (format t "\"3.14\" as float: ~a~%" (read-from-string float-str))
  ;; Number to string
  (format t "42 as string: ~s~%" (write-to-string 42))
  (format t "3.14 as string: ~s~%" (write-to-string 3.14)))

;; Character conversions
(format t "~%Character conversions:~%")
(format t "Code 65 to char: ~a~%" (code-char 65))
(format t "#\\A to code: ~a~%" (char-code #\A))
(format t "#\\a uppercase: ~a~%" (char-upcase #\a))
(format t "Char to string: ~s~%" (string #\Z))

;; List / vector interconversion
(format t "~%Sequence conversions:~%")
(let ((lst '(1 2 3 4 5))
      (vec #(10 20 30)))
  (format t "List to vector: ~a~%" (coerce lst 'vector))
  (format t "Vector to list: ~a~%" (coerce vec 'list))
  (format t "String to list: ~a~%" (coerce "hello" 'list))
  (format t "List to string: ~a~%" (coerce '(#\h #\i) 'string)))

Running with Docker

1
docker run --rm -v $(pwd):/app -w /app clfoundation/sbcl:latest sbcl --script variables_conversion.lisp

Expected Output

42 as float: 42.0
3.7 truncated: 3
3.7 floored: 3
3.7 ceiling: 4
3.7 rounded: 4
7/2 as float: 3.5
3.14 as rational: 7070651414971679/2251799813685248

String/Number conversions:
"42" as number: 42
"3.14" as float: 3.14
42 as string: "42"
3.14 as string: "3.14"

Character conversions:
Code 65 to char: A
#\A to code: 65
#\a uppercase: A
Char to string: "Z"

Sequence conversions:
List to vector: #(1 2 3 4 5)
Vector to list: (10 20 30)
String to list: (h e l l o)
List to string: hi

Key Concepts

  • Values carry types, not variables(type-of x) tells you what the value is, regardless of how x was bound
  • let vs let*let binds all variables in parallel before the body; let* binds sequentially so later bindings can reference earlier ones
  • defvar vs defparameterdefvar is idempotent (won’t overwrite on reload); defparameter always reassigns, making it better for development configs
  • Earmuffs convention — Dynamic (global) variables are named *like-this*; constants use +like-this+; this is a strong community convention, not a language rule
  • NIL is both false and the empty listnil, '(), and () are all identical; any non-NIL value is truthy in a boolean context
  • Symbols are unique, interned objects'hello and 'hello are the same object; keywords (:keyword) are self-evaluating symbols in the KEYWORD package
  • Arbitrary precision integers — Common Lisp integers never overflow; they grow as large as memory allows (called bignums)
  • coerce for sequence conversion — Moving between lists, vectors, and strings uses the generic coerce function with a target type specifier

Running Today

All examples can be run using Docker:

docker pull clfoundation/sbcl:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining