Skip to main content
User-defined types (UDTs) allow you to create custom structured data types in CQL that map to Rust structs. UDTs are similar to C structs or Rust structs with named fields.

Overview

A UDT in CQL is a collection of named, typed fields. In Rust, you can use derive macros to automatically implement serialization and deserialization for your structs.

Creating a UDT in CQL

First, create the type in your database:
CREATE TYPE IF NOT EXISTS address (
    street text,
    city text,
    zip_code text,
    country text
);

CREATE TABLE users (
    id int PRIMARY KEY,
    name text,
    address address
);

Basic Usage

Deriving Traits

Use the SerializeValue and DeserializeValue derive macros:
use scylla::{SerializeValue, DeserializeValue};

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    zip_code: String,
    country: String,
}

// Insert a UDT
let address = Address {
    street: "123 Main St".to_string(),
    city: "Springfield".to_string(),
    zip_code: "12345".to_string(),
    country: "USA".to_string(),
};

session.query_unpaged(
    "INSERT INTO users (id, name, address) VALUES (?, ?, ?)",
    (1, "Alice", address),
).await?;

// Read back
let result = session
    .query_unpaged("SELECT address FROM users WHERE id = 1", &[])
    .await?;

if let Some(row) = result.rows()?.first() {
    let address: Address = /* deserialize from row */;
    println!("Address: {} {}, {}", address.street, address.city, address.country);
}

Field Names

By default, the derive macro uses the Rust field name as-is. If your CQL field names differ, use the rename attribute:
use scylla::{SerializeValue, DeserializeValue};

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    #[scylla(rename = "zip_code")]
    zip: String,
    country: String,
}

Nullable Fields

Use Option<T> for nullable fields:
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    zip_code: Option<String>,  // Can be null
    country: String,
}

let address = Address {
    street: "123 Main St".to_string(),
    city: "Springfield".to_string(),
    zip_code: None,  // No zip code
    country: "USA".to_string(),
};

Nested UDTs

UDTs can contain other UDTs:
CREATE TYPE phone (
    country_code text,
    number text
);

CREATE TYPE contact_info (
    email text,
    phone phone
);

CREATE TABLE users (
    id int PRIMARY KEY,
    name text,
    contact contact_info
);
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Phone {
    country_code: String,
    number: String,
}

#[derive(Debug, SerializeValue, DeserializeValue)]
struct ContactInfo {
    email: String,
    phone: Phone,
}

let contact = ContactInfo {
    email: "[email protected]".to_string(),
    phone: Phone {
        country_code: "+1".to_string(),
        number: "5551234567".to_string(),
    },
};

session.query_unpaged(
    "INSERT INTO users (id, name, contact) VALUES (?, ?, ?)",
    (1, "Alice", contact),
).await?;

UDTs with Collections

UDTs can contain collection types:
CREATE TYPE person (
    name text,
    emails set<text>,
    phone_numbers map<text, text>
);
use std::collections::{HashSet, HashMap};

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Person {
    name: String,
    emails: HashSet<String>,
    phone_numbers: HashMap<String, String>,
}

let mut emails = HashSet::new();
emails.insert("[email protected]".to_string());
emails.insert("[email protected]".to_string());

let mut phones = HashMap::new();
phones.insert("home".to_string(), "555-1234".to_string());
phones.insert("mobile".to_string(), "555-5678".to_string());

let person = Person {
    name: "Alice".to_string(),
    emails,
    phone_numbers: phones,
};

session.query_unpaged(
    "INSERT INTO people (id, info) VALUES (?, ?)",
    (1, person),
).await?;

Collections of UDTs

You can have collections containing UDTs:
CREATE TYPE address (
    street text,
    city text,
    country text
);

CREATE TABLE users (
    id int PRIMARY KEY,
    name text,
    addresses list<frozen<address>>
);
#[derive(Debug, Clone, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    country: String,
}

let addresses = vec![
    Address {
        street: "123 Main St".to_string(),
        city: "Springfield".to_string(),
        country: "USA".to_string(),
    },
    Address {
        street: "456 Oak Ave".to_string(),
        city: "Portland".to_string(),
        country: "USA".to_string(),
    },
];

session.query_unpaged(
    "INSERT INTO users (id, name, addresses) VALUES (?, ?, ?)",
    (1, "Alice", addresses),
).await?;

Partial Updates

CQL allows updating individual fields of a UDT:
// Update just the city field
session.query_unpaged(
    "UPDATE users SET address.city = ? WHERE id = ?",
    ("New York", 1),
).await?;

// Update multiple fields
session.query_unpaged(
    "UPDATE users SET address.city = ?, address.country = ? WHERE id = ?",
    ("London", "UK", 1),
).await?;

Working with CqlValue

For dynamic UDT handling, use CqlValue:
use scylla::value::CqlValue;
use std::collections::HashMap;

let address = CqlValue::UserDefinedType {
    keyspace: "my_keyspace".to_string(),
    name: "address".to_string(),
    fields: vec![
        ("street".to_string(), Some(CqlValue::Text("123 Main St".to_string()))),
        ("city".to_string(), Some(CqlValue::Text("Springfield".to_string()))),
        ("zip_code".to_string(), Some(CqlValue::Text("12345".to_string()))),
        ("country".to_string(), Some(CqlValue::Text("USA".to_string()))),
    ],
};

session.query_unpaged(
    "INSERT INTO users (id, address) VALUES (?, ?)",
    (1, address),
).await?;

Field Ordering

The order of fields in your Rust struct must match the order in the CQL type definition:
CREATE TYPE address (
    street text,      -- 1st field
    city text,        -- 2nd field
    zip_code text,    -- 3rd field
    country text      -- 4th field
);
// Correct: fields in same order as CQL
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,    // 1st
    city: String,      // 2nd
    zip_code: String,  // 3rd
    country: String,   // 4th
}

// Wrong: fields in different order
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    city: String,      // Wrong order!
    street: String,
    country: String,
    zip_code: String,
}

Missing Fields

If your Rust struct has fewer fields than the CQL type, the extra CQL fields will be null:
// CQL type has 4 fields, Rust struct has 3
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    zip_code: String,
    // 'country' field will be null in the database
}

Frozen vs Non-Frozen

UDTs can be frozen or non-frozen:
  • Frozen UDT: Treated as a single blob, cannot update individual fields, more efficient
  • Non-frozen UDT: Can update individual fields, less efficient
-- Non-frozen (can update individual fields)
CREATE TABLE users (
    id int PRIMARY KEY,
    address address
);

-- Frozen (treat as blob, more efficient)
CREATE TABLE users (
    id int PRIMARY KEY,
    address frozen<address>
);
In Rust, the same struct works for both:
#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    zip_code: String,
    country: String,
}

// Works for both frozen and non-frozen UDTs

Complete Example

use scylla::{Session, SessionBuilder, SerializeValue, DeserializeValue};
use std::error::Error;

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    zip_code: Option<String>,
    country: String,
}

#[derive(Debug, SerializeValue, DeserializeValue)]
struct User {
    name: String,
    email: String,
    address: Address,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let session: Session = SessionBuilder::new()
        .known_node("127.0.0.1:9042")
        .build()
        .await?;

    // Create keyspace and type
    session.query_unpaged(
        "CREATE KEYSPACE IF NOT EXISTS my_keyspace WITH REPLICATION = {
            'class': 'SimpleStrategy',
            'replication_factor': 1
        }",
        &[],
    ).await?;

    session.query_unpaged("USE my_keyspace", &[]).await?;

    session.query_unpaged(
        "CREATE TYPE IF NOT EXISTS address (
            street text,
            city text,
            zip_code text,
            country text
        )",
        &[],
    ).await?;

    session.query_unpaged(
        "CREATE TABLE IF NOT EXISTS users (
            id int PRIMARY KEY,
            name text,
            email text,
            address address
        )",
        &[],
    ).await?;

    // Insert a user
    let user = User {
        name: "Alice".to_string(),
        email: "[email protected]".to_string(),
        address: Address {
            street: "123 Main St".to_string(),
            city: "Springfield".to_string(),
            zip_code: Some("12345".to_string()),
            country: "USA".to_string(),
        },
    };

    session.query_unpaged(
        "INSERT INTO users (id, name, email, address) VALUES (?, ?, ?, ?)",
        (1, &user.name, &user.email, &user.address),
    ).await?;

    // Read back
    let result = session
        .query_unpaged("SELECT name, email, address FROM users WHERE id = 1", &[])
        .await?;

    if let Some(row) = result.rows()?.first() {
        println!("User: {:?}", row);
    }

    Ok(())
}

Best Practices

  1. Match field order: Ensure Rust struct fields are in the same order as CQL type definition
  2. Use Option for nullable fields: Mark fields that can be null as Option<T>
  3. Consider frozen types: Use frozen UDTs in collections and when you don’t need partial updates
  4. Keep UDTs simple: Avoid deeply nested structures that are hard to maintain
  5. Version carefully: Changing UDT definitions can be tricky; consider creating new types instead

Common Patterns

Address Information

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Address {
    street: String,
    city: String,
    state: Option<String>,
    zip_code: String,
    country: String,
}

Contact Information

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Contact {
    email: String,
    phone: Option<String>,
    preferred_contact_method: String,
}

Geolocation

#[derive(Debug, SerializeValue, DeserializeValue)]
struct Location {
    latitude: f64,
    longitude: f64,
    accuracy: Option<f32>,
}

Time Range

use chrono::{DateTime, Utc};

#[derive(Debug, SerializeValue, DeserializeValue)]
struct TimeRange {
    start: DateTime<Utc>,
    end: Option<DateTime<Utc>>,
}

See Also

Build docs developers (and LLMs) love