diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 605f8aa..e611f19 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -54,11 +54,11 @@ jobs:
     steps:
     - uses: actions/checkout@v3
     - name: Install Rust
-      run: rustup update stable
+      run: rustup update nightly
     - name: Install cargo-llvm-cov
       uses: taiki-e/install-action@cargo-llvm-cov
     - name: Generate code coverage
-      run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
+      run: cargo +nightly llvm-cov --all-features --workspace --lcov --output-path lcov.info
     - name: Upload coverage to Codecov
       uses: codecov/codecov-action@v3
       with:
diff --git a/Cargo.toml b/Cargo.toml
index d604e1e..7678f46 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "utc-dt"
-version = "0.2.1"
+version = "0.3.0"
 authors = ["Reece Kibble <reecek@uniciant.com>"]
 categories = ["date-and-time", "no-std", "parsing"]
 keywords = ["time", "datetime", "date", "utc", "epoch"]
@@ -17,7 +17,16 @@ exclude = [".git*"]
 
 [features]
 default = ["std"]
-std = ["anyhow/std"]
+std = [
+    "alloc",
+    "serde/std",
+]
+alloc = ["serde/alloc"]
+serde = ["dep:serde"]
+nightly = []
 
 [dependencies]
-anyhow = { version = "1", default-features = false }
+serde = { version = "1.0", default-features = false, optional = true, features = ["derive"] }
+
+[dev-dependencies]
+serde_json = "1.0"
diff --git a/README.md b/README.md
index f3517db..ff941c1 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
 
 [![crates.io](https://img.shields.io/crates/v/utc-dt?style=flat-square&logo=rust)](https://crates.io/crates/utc-dt)
 [![license](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue?style=flat-square)](#license)
+[![docs](https://img.shields.io/docsrs/utc-dt/latest)](https://docs.rs/utc-dt)
 [![build status](https://img.shields.io/github/actions/workflow/status/uniciant/utc-datetime/rust.yml?branch=main&style=flat-square&logo=github)](https://github.com/uniciant/utc-datetime/actions)
 [![codecov](https://codecov.io/gh/uniciant/utc-datetime/branch/main/graph/badge.svg?token=XTOHZ187TY)](https://codecov.io/gh/uniciant/utc-datetime)
 
@@ -12,7 +13,7 @@ It prioritizes being space-optimal and efficient.
 
 ```toml
 [dependencies]
-utc-dt = "0.2"
+utc-dt = "0.3"
 ```
 For extended/niche features and local time-zone support see [`chrono`](https://github.com/chronotope/chrono) or [`time`](https://github.com/time-rs/time).
 
@@ -33,7 +34,8 @@ See [docs.rs](https://docs.rs/utc-dt) for the API reference.
 - Provides constants useful for time transformations: [`utc-dt::constants`](https://docs.rs/utc-dt/latest/utc_dt/constants/index.html)
 - Nanosecond resolution.
 - Timestamps supporting standard math operators (`core::ops`)
-- `#![no_std]` support.
+- `#![no_std]` and optional `alloc` support.
+- Optional serialization/deserialization of structures via `serde`
 
 ## Examples (exhaustive)
  ```rust
@@ -99,13 +101,16 @@ See [docs.rs](https://docs.rs/utc-dt) for the API reference.
     // UTC Time of Day subsecond component (in nanoseconds)
     let subsec_ns = utc_tod.as_subsec_ns();
     // Parse a UTC Time of Day from an ISO 8601 time string `(Thh:mm:ssZ)`
-    // Not available for #![no_std]
     let utc_tod = UTCTimeOfDay::try_from_iso_tod("T10:18:08.903Z").unwrap();
     // Get a time of day string formatted according to ISO 8601 `(Thh:mm:ssZ)`
-    // Not available for #![no_std]
-    let precision = Some(6);
-    let iso_tod = utc_tod.as_iso_tod(precision);
+    const PRECISION_MICROS: usize = 6;
+    let iso_tod = utc_tod.as_iso_tod(PRECISION_MICROS);
     assert_eq!(iso_tod, "T10:18:08.903000Z");
+    // Write ISO 8601 time of day str to a stack buffer
+    let mut buf = [0; UTCTimeOfDay::iso_tod_len(PRECISION_MICROS)];
+    let _bytes_written = utc_tod.write_iso_tod(&mut buf, PRECISION_MICROS).unwrap();
+    let iso_tod_str = core::str::from_utf8(&buf).unwrap();
+    assert_eq!(iso_tod_str, "T10:18:08.903000Z");
 
     // UTC Date directly from components
     let utc_date = UTCDate::try_from_components(2023, 6, 15).unwrap(); // OR
@@ -121,12 +126,15 @@ See [docs.rs](https://docs.rs/utc-dt) for the API reference.
     // UTC Day from UTC Date
     let utc_day = utc_date.as_day();
     // Parse a UTC Date from an ISO 8601 date string `(YYYY-MM-DD)`
-    // Not available for #![no_std]
     let utc_date = UTCDate::try_from_iso_date("2023-06-15").unwrap();
     // Get date string formatted according to ISO 8601 `(YYYY-MM-DD)`
-    // Not available for #![no_std]
     let iso_date = utc_date.as_iso_date();
     assert_eq!(iso_date, "2023-06-15");
+    // Write ISO 8601 date str to a stack buffer
+    let mut buf = [0; UTCDate::ISO_DATE_LEN];
+    let _bytes_written = utc_date.write_iso_date(&mut buf).unwrap();
+    let iso_date_str = core::str::from_utf8(&buf).unwrap();
+    assert_eq!(iso_date_str, "2023-06-15");
 
     // UTC Datetime from date and time-of-day components
     let utc_datetime = UTCDatetime::from_components(utc_date, utc_tod);
@@ -134,13 +142,16 @@ See [docs.rs](https://docs.rs/utc-dt) for the API reference.
     let (utc_date, time_of_day_ns) = (utc_datetime.as_date(), utc_datetime.as_tod()); // OR
     let (utc_date, time_of_day_ns) = utc_datetime.as_components();
     // Parse a UTC Datetime from an ISO 8601 datetime string `(YYYY-MM-DDThh:mm:ssZ)`
-    // Not available for #![no_std]
     let utc_datetime = UTCDatetime::try_from_iso_datetime("2023-06-15T10:18:08.903Z").unwrap();
     // Get UTC datetime string formatted according to ISO 8601 `(YYYY-MM-DDThh:mm:ssZ)`
-    // Not available for #![no_std]
-    let precision = None;
-    let iso_datetime = utc_datetime.as_iso_datetime(precision);
+    const PRECISION_SECONDS: usize = 0;
+    let iso_datetime = utc_datetime.as_iso_datetime(PRECISION_SECONDS);
     assert_eq!(iso_datetime, "2023-06-15T10:18:08Z");
+    // Write ISO 8601 datetime str to a stack buffer
+    let mut buf = [0; UTCDatetime::iso_datetime_len(PRECISION_SECONDS)];
+    let _bytes_written = utc_datetime.write_iso_datetime(&mut buf, PRECISION_SECONDS).unwrap();
+    let iso_datetime_str = core::str::from_utf8(&buf).unwrap();
+    assert_eq!(iso_datetime_str, "2023-06-15T10:18:08Z");
 
     {
         // `UTCTransformations` can be used to create shortcuts to the desired type!
@@ -184,6 +195,13 @@ See [docs.rs](https://docs.rs/utc-dt) for the API reference.
     }
 ```
 
+## Feature flags
+The [`std`, `alloc`] feature flags are enabled by default.
+- `std`: Enables methods that use the system clock via `std::time::SystemTime`. Enables `alloc`.
+- `alloc`: Enables methods that use allocated strings.
+- `serde`: Derives `serde::Serialize` and `serde::Deserialize` for all internal non-error types.
+- `nightly`: Enables the unstable [`error_in_core`](https://github.com/rust-lang/rust/issues/103765) feature for improved `#[no_std]` error handling.
+
 ## References
 - [(Howard Hinnant, 2021) `chrono`-Compatible Low-Level Date Algorithms](http://howardhinnant.github.io/date_algorithms.html)
 - [(W3C, 1997) ISO 8601 Standard for Date and Time Formats](https://www.w3.org/TR/NOTE-datetime)
diff --git a/src/date.rs b/src/date.rs
index b2b2acc..77850d7 100644
--- a/src/date.rs
+++ b/src/date.rs
@@ -4,14 +4,20 @@
 //! proleptic Gregorian Calendar (the *civil* calendar),
 //! to create UTC dates.
 
-use core::{
-    fmt::{Display, Formatter},
-    time::Duration,
-};
+use crate::time::{UTCDay, UTCTimestamp, UTCTransformations};
+use crate::util::StrWriter;
+use core::fmt::{Display, Formatter, Write};
+use core::num::ParseIntError;
+use core::time::Duration;
 
-use anyhow::{bail, Result};
+#[cfg(feature = "alloc")]
+use alloc::{format, string::String};
 
-use crate::time::{UTCDay, UTCTimestamp, UTCTransformations};
+// TODO <https://github.com/rust-lang/rust/issues/103765>
+#[cfg(feature = "nightly")]
+use core::error::Error;
+#[cfg(all(feature = "std", not(feature = "nightly")))]
+use std::error::Error;
 
 /// UTC Date.
 ///
@@ -45,11 +51,17 @@ use crate::time::{UTCDay, UTCTimestamp, UTCTransformations};
 /// // Not available for #![no_std]
 /// let iso_date = utc_date.as_iso_date();
 /// assert_eq!(iso_date, "2023-06-15");
+/// // Write ISO 8601 date str to a stack buffer
+/// let mut buf = [0; UTCDate::ISO_DATE_LEN];
+/// let _bytes_written = utc_date.write_iso_date(&mut buf).unwrap();
+/// let iso_date_str = core::str::from_utf8(&buf).unwrap();
+/// assert_eq!(iso_date_str, "2023-06-15");
 /// ```
 ///
 /// ## Safety
 /// Unchecked methods are provided for use in hot paths requiring high levels of optimisation.
 /// These methods assume valid input.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct UTCDate {
     era: u32,
@@ -101,6 +113,9 @@ impl UTCDate {
     /// The minimum year supported
     pub const MIN_YEAR: u64 = 1970;
 
+    /// The length of an ISO date (in characters)
+    pub const ISO_DATE_LEN: usize = 10;
+
     /// Unchecked method to create a UTC Date from provided year, month and day.
     ///
     /// ## Safety
@@ -120,21 +135,21 @@ impl UTCDate {
     }
 
     /// Try to create a UTC Date from provided year, month and day.
-    pub fn try_from_components(year: u64, month: u8, day: u8) -> Result<Self> {
+    pub fn try_from_components(year: u64, month: u8, day: u8) -> Result<Self, UTCDateError> {
         if !(Self::MIN_YEAR..=Self::MAX_YEAR).contains(&year) {
-            bail!("Year out of range! (year: {:04})", year);
+            return Err(UTCDateError::YearOutOfRange(year));
         }
         if month == 0 || month > 12 {
-            bail!("Month out of range! (month: {:02})", month);
+            return Err(UTCDateError::MonthOutOfRange(month));
         }
-        // force create
+        // SAFETY: we have checked year and month are within range
         let date = unsafe { Self::from_components_unchecked(year, month, day) };
-        // then check
+        // Then check days
         if date.day == 0 || date.day > date.days_in_month() {
-            bail!("Day out of range! (date: {date}");
+            return Err(UTCDateError::DayOutOfRange(date));
         }
         if date > UTCDate::MAX {
-            bail!("Date out of range! (date: {date}");
+            return Err(UTCDateError::DateOutOfRange(date));
         }
         Ok(date)
     }
@@ -176,6 +191,7 @@ impl UTCDate {
         let doy = ((153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5) + d - 1;
         let doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy as u32;
         let days = (era as u64 * 146097) + doe as u64 - 719468;
+        // SAFETY: days is not exceeding UTCDay::MAX
         unsafe { UTCDay::from_u64_unchecked(days) }
     }
 
@@ -223,13 +239,16 @@ impl UTCDate {
         }
     }
 
-    /// Try parse date from string in the format:
+    /// Try parse date from str in the format:
     /// * `YYYY-MM-DD`
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
-    pub fn try_from_iso_date(iso: &str) -> Result<Self> {
+    pub fn try_from_iso_date(iso: &str) -> Result<Self, UTCDateError> {
+        let len = iso.len();
+        if len != Self::ISO_DATE_LEN {
+            return Err(UTCDateError::InvalidStrLen(len));
+        }
         // handle slice
         let (year_str, rem) = iso.split_at(4); // remainder = "-MM-DD"
         let (month_str, rem) = rem[1..].split_at(2); // remainder = "-DD"
@@ -246,10 +265,38 @@ impl UTCDate {
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
+    #[cfg(feature = "alloc")]
     pub fn as_iso_date(&self) -> String {
         format!("{self}")
     }
+
+    /// Internal truncated buffer write
+    #[inline]
+    pub(crate) fn _write_iso_date_trunc(&self, w: &mut StrWriter) {
+        // unwrap infallible
+        write!(w, "{self}").unwrap();
+    }
+
+    /// Write an ISO date to a buffer in the format:
+    /// * `YYYY-MM-DD`
+    ///
+    /// The buffer should have minimum length of [UTCDate::ISO_DATE_LEN] (10).
+    ///
+    /// A buffer of insufficient length will error ([UTCDateError::InvalidStrLen]).
+    ///
+    /// Returns number of UTF8 characters (bytes) written
+    ///
+    /// Conforms to ISO 8601:
+    /// <https://www.w3.org/TR/NOTE-datetime>
+    pub fn write_iso_date(&self, buf: &mut [u8]) -> Result<usize, UTCDateError> {
+        let write_len = Self::ISO_DATE_LEN;
+        if write_len > buf.len() {
+            return Err(UTCDateError::InvalidStrLen(buf.len()));
+        }
+        let mut writer = StrWriter::new(&mut buf[..write_len]);
+        self._write_iso_date_trunc(&mut writer);
+        Ok(writer.written)
+    }
 }
 
 impl UTCTransformations for UTCDate {
@@ -316,3 +363,49 @@ impl From<UTCDay> for UTCDate {
         Self::from_day(utc_day)
     }
 }
+
+/// Error type for UTCDate methods
+#[derive(Debug, Clone)]
+pub enum UTCDateError {
+    /// Error raised parsing int to string
+    ParseErr(ParseIntError),
+    /// Error raised due to out of range year
+    YearOutOfRange(u64),
+    /// Error raised due to out of range month
+    MonthOutOfRange(u8),
+    /// Error raised due to out of range day
+    DayOutOfRange(UTCDate),
+    /// Error raised due to out of range date
+    DateOutOfRange(UTCDate),
+    /// Error raised due to invalid ISO date length
+    InvalidStrLen(usize),
+}
+
+impl Display for UTCDateError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+        match self {
+            Self::ParseErr(e) => e.fmt(f),
+            Self::YearOutOfRange(y) => write!(f, "year ({y}) out of range!"),
+            Self::MonthOutOfRange(m) => write!(f, "month ({m}) out of range!"),
+            Self::DayOutOfRange(d) => write!(f, "day ({d}) out of range!"),
+            Self::DateOutOfRange(date) => write!(f, "date ({date}) out of range!"),
+            Self::InvalidStrLen(l) => write!(f, "invalid ISO date str length ({l}), 10 required"),
+        }
+    }
+}
+
+#[cfg(any(feature = "std", feature = "nightly"))]
+impl Error for UTCDateError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            Self::ParseErr(e) => e.source(),
+            _ => None,
+        }
+    }
+}
+
+impl From<ParseIntError> for UTCDateError {
+    fn from(value: ParseIntError) -> Self {
+        Self::ParseErr(value)
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3e261e1..d85d9b0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@
 //!
 //! [![crates.io](https://img.shields.io/crates/v/utc-dt?style=flat-square&logo=rust)](https://crates.io/crates/utc-dt)
 //! [![license](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue?style=flat-square)](#license)
+//! [![docs](https://img.shields.io/docsrs/utc-dt/latest)](https://docs.rs/utc-dt)
 //! [![build status](https://img.shields.io/github/actions/workflow/status/uniciant/utc-datetime/rust.yml?branch=main&style=flat-square&logo=github)](https://github.com/uniciant/utc-datetime/actions)
 //! [![codecov](https://codecov.io/gh/uniciant/utc-datetime/branch/main/graph/badge.svg?token=XTOHZ187TY)](https://codecov.io/gh/uniciant/utc-datetime)
 //!
@@ -12,7 +13,7 @@
 //!
 //! ```rust,ignore
 //! [dependencies]
-//! utc-dt = "0.2"
+//! utc-dt = "0.3"
 //! ```
 //! For extended/niche features and local time-zone support see [`chrono`](https://github.com/chronotope/chrono) or [`time`](https://github.com/time-rs/time).
 //!
@@ -33,7 +34,8 @@
 //! - Provides constants useful for time transformations: [`utc-dt::constants`](https://docs.rs/utc-dt/latest/utc_dt/constants/index.html)
 //! - Nanosecond resolution.
 //! - Timestamps supporting standard math operators (`core::ops`)
-//! - `#![no_std]` support.
+//! - `#![no_std]` and optional `alloc` support.
+//! - Optional serialization/deserialization of structures via `serde`
 //!
 //! ## Examples (exhaustive)
 #![cfg_attr(not(feature = "std"), doc = "```rust,ignore")]
@@ -100,13 +102,16 @@
 //!     // UTC Time of Day subsecond component (in nanoseconds)
 //!     let subsec_ns = utc_tod.as_subsec_ns();
 //!     // Parse a UTC Time of Day from an ISO 8601 time string `(Thh:mm:ssZ)`
-//!     // Not available for #![no_std]
 //!     let utc_tod = UTCTimeOfDay::try_from_iso_tod("T10:18:08.903Z").unwrap();
 //!     // Get a time of day string formatted according to ISO 8601 `(Thh:mm:ssZ)`
-//!     // Not available for #![no_std]
-//!     let precision = Some(6);
-//!     let iso_tod = utc_tod.as_iso_tod(precision);
+//!     const PRECISION_MICROS: usize = 6;
+//!     let iso_tod = utc_tod.as_iso_tod(PRECISION_MICROS);
 //!     assert_eq!(iso_tod, "T10:18:08.903000Z");
+//!     // Write ISO 8601 time of day str to a stack buffer
+//!     let mut buf = [0; UTCTimeOfDay::iso_tod_len(PRECISION_MICROS)];
+//!     let _bytes_written = utc_tod.write_iso_tod(&mut buf, PRECISION_MICROS).unwrap();
+//!     let iso_tod_str = core::str::from_utf8(&buf).unwrap();
+//!     assert_eq!(iso_tod_str, "T10:18:08.903000Z");
 //!
 //!     // UTC Date directly from components
 //!     let utc_date = UTCDate::try_from_components(2023, 6, 15).unwrap(); // OR
@@ -122,12 +127,15 @@
 //!     // UTC Day from UTC Date
 //!     let utc_day = utc_date.as_day();
 //!     // Parse a UTC Date from an ISO 8601 date string `(YYYY-MM-DD)`
-//!     // Not available for #![no_std]
 //!     let utc_date = UTCDate::try_from_iso_date("2023-06-15").unwrap();
 //!     // Get date string formatted according to ISO 8601 `(YYYY-MM-DD)`
-//!     // Not available for #![no_std]
 //!     let iso_date = utc_date.as_iso_date();
 //!     assert_eq!(iso_date, "2023-06-15");
+//!     // Write ISO 8601 date str to a stack buffer
+//!     let mut buf = [0; UTCDate::ISO_DATE_LEN];
+//!     let _bytes_written = utc_date.write_iso_date(&mut buf).unwrap();
+//!     let iso_date_str = core::str::from_utf8(&buf).unwrap();
+//!     assert_eq!(iso_date_str, "2023-06-15");
 //!
 //!     // UTC Datetime from date and time-of-day components
 //!     let utc_datetime = UTCDatetime::from_components(utc_date, utc_tod);
@@ -135,13 +143,16 @@
 //!     let (utc_date, time_of_day_ns) = (utc_datetime.as_date(), utc_datetime.as_tod()); // OR
 //!     let (utc_date, time_of_day_ns) = utc_datetime.as_components();
 //!     // Parse a UTC Datetime from an ISO 8601 datetime string `(YYYY-MM-DDThh:mm:ssZ)`
-//!     // Not available for #![no_std]
 //!     let utc_datetime = UTCDatetime::try_from_iso_datetime("2023-06-15T10:18:08.903Z").unwrap();
 //!     // Get UTC datetime string formatted according to ISO 8601 `(YYYY-MM-DDThh:mm:ssZ)`
-//!     // Not available for #![no_std]
-//!     let precision = None;
-//!     let iso_datetime = utc_datetime.as_iso_datetime(precision);
+//!     const PRECISION_SECONDS: usize = 0;
+//!     let iso_datetime = utc_datetime.as_iso_datetime(PRECISION_SECONDS);
 //!     assert_eq!(iso_datetime, "2023-06-15T10:18:08Z");
+//!     // Write ISO 8601 datetime str to a stack buffer
+//!     let mut buf = [0; UTCDatetime::iso_datetime_len(PRECISION_SECONDS)];
+//!     let _bytes_written = utc_datetime.write_iso_datetime(&mut buf, PRECISION_SECONDS).unwrap();
+//!     let iso_datetime_str = core::str::from_utf8(&buf).unwrap();
+//!     assert_eq!(iso_datetime_str, "2023-06-15T10:18:08Z");
 //!
 //!     {
 //!         // `UTCTransformations` can be used to create shortcuts to the desired type!
@@ -185,6 +196,13 @@
 //!     }
 //! ```
 //!
+//! ## Feature flags
+//! The [`std`, `alloc`] feature flags are enabled by default.
+//! - `std`: Enables methods that use the system clock via `std::time::SystemTime`. Enables `alloc`.
+//! - `alloc`: Enables methods that use allocated strings.
+//! - `serde`: Derives `serde::Serialize` and `serde::Deserialize` for all internal non-error types.
+//! - `nightly`: Enables the unstable [`error_in_core`](https://github.com/rust-lang/rust/issues/103765) feature for improved `#[no_std]` error handling.
+//!
 //! ## References
 //! - [(Howard Hinnant, 2021) `chrono`-Compatible Low-Level Date Algorithms](http://howardhinnant.github.io/date_algorithms.html)
 //! - [(W3C, 1997) ISO 8601 Standard for Date and Time Formats](https://www.w3.org/TR/NOTE-datetime)
@@ -199,22 +217,34 @@
 #![warn(missing_debug_implementations)]
 #![warn(dead_code)]
 #![cfg_attr(not(feature = "std"), no_std)]
+// TODO <https://github.com/rust-lang/rust/issues/103765>
+// Releasing in stable rust 1.81.0, 5th september!
+#![cfg_attr(all(not(feature = "std"), feature = "nightly"), feature(error_in_core))]
+
+#[cfg(feature = "alloc")]
+extern crate alloc;
 
 pub mod date;
 pub mod time;
 #[rustfmt::skip]
 pub mod constants;
+mod util;
 
-use core::{
-    fmt::{Display, Formatter},
-    time::Duration,
-};
+use crate::date::{UTCDate, UTCDateError};
+use crate::time::{UTCTimeOfDay, UTCTimeOfDayError, UTCTimestamp, UTCTransformations};
+use core::fmt::{Display, Formatter};
+use core::time::Duration;
 
-#[cfg(feature = "std")]
-use anyhow::Result;
+#[cfg(feature = "alloc")]
+use alloc::string::String;
+use time::UTCDayErrOutOfRange;
+use util::StrWriter;
 
-use date::UTCDate;
-use time::{UTCTimeOfDay, UTCTimestamp, UTCTransformations};
+// TODO <https://github.com/rust-lang/rust/issues/103765>
+#[cfg(feature = "nightly")]
+use core::error::Error;
+#[cfg(all(feature = "std", not(feature = "nightly")))]
+use std::error::Error;
 
 /// UTC Datetime.
 ///
@@ -241,12 +271,17 @@ use time::{UTCTimeOfDay, UTCTimestamp, UTCTransformations};
 /// // Parse a UTC Datetime from an ISO 8601 datetime string `(YYYY-MM-DDThh:mm:ssZ)`
 /// let utc_datetime = UTCDatetime::try_from_iso_datetime("2023-06-15T10:18:08.903Z").unwrap();
 /// // Get UTC datetime string formatted according to ISO 8601 `(YYYY-MM-DDThh:mm:ssZ)`
-/// // Not available for #![no_std]
-/// let precision = None;
-/// let iso_datetime = utc_datetime.as_iso_datetime(precision);
+/// const PRECISION_SECONDS: usize = 0;
+/// let iso_datetime = utc_datetime.as_iso_datetime(PRECISION_SECONDS);
 /// assert_eq!(iso_datetime, "2023-06-15T10:18:08Z");
+/// // Write ISO 8601 datetime str to a stack buffer
+/// let mut buf = [0; UTCDatetime::iso_datetime_len(PRECISION_SECONDS)];
+/// let _bytes_written = utc_datetime.write_iso_datetime(&mut buf, PRECISION_SECONDS).unwrap();
+/// let iso_datetime_str = core::str::from_utf8(&buf).unwrap();
+/// assert_eq!(iso_datetime_str, "2023-06-15T10:18:08Z");
 /// ```
 ///
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
 pub struct UTCDatetime {
     date: UTCDate,
@@ -276,9 +311,13 @@ impl UTCDatetime {
     /// UTCDatetime can physically store dates up to `December 31, 1_717_986_918_399, T23:59:59.999999999Z`
     pub const MAX: UTCDatetime = Self {
         date: UTCDate::MAX,
+        // SAFETY: nanos is within NANOS_PER_DAY
         tod: unsafe { UTCTimeOfDay::from_nanos_unchecked(25215999999999) },
     };
 
+    /// The minimum length of an ISO datetime (in UTF8 characters)
+    pub const MIN_ISO_DATETIME_LEN: usize = UTCTimeOfDay::MIN_ISO_TOD_LEN + UTCDate::ISO_DATE_LEN;
+
     /// Create a datetime frome date and time-of-day components.
     #[inline]
     pub const fn from_components(date: UTCDate, tod: UTCTimeOfDay) -> Self {
@@ -313,7 +352,7 @@ impl UTCDatetime {
         self.tod
     }
 
-    /// Try parse datetime from string in the format:
+    /// Try parse datetime from str in the format:
     ///
     /// * `YYYY-MM-DDThh:mm:ssZ` or
     /// * `YYYY-MM-DDThh:mm:ss.nnnZ`
@@ -322,8 +361,14 @@ impl UTCDatetime {
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
-    pub fn try_from_iso_datetime(iso: &str) -> Result<Self> {
+    pub fn try_from_iso_datetime(iso: &str) -> Result<Self, UTCDatetimeError> {
+        let len = iso.len();
+        if len < Self::MIN_ISO_DATETIME_LEN {
+            return Err(UTCDatetimeError::InsufficientStrLen(
+                len,
+                Self::MIN_ISO_DATETIME_LEN,
+            ));
+        }
         let (date_str, tod_str) = iso.split_at(10);
         let date = UTCDate::try_from_iso_date(date_str)?;
         let tod = UTCTimeOfDay::try_from_iso_tod(tod_str)?;
@@ -331,19 +376,52 @@ impl UTCDatetime {
     }
 
     /// Return datetime as a string in the format:
-    /// * Precision = `None`: `YYYY-MM-DDThh:mm:ssZ`
-    /// * Precision = `Some(3)`: `YYYY-MM-DDThh:mm:ss.nnnZ`
+    /// * Precision = `0`: `YYYY-MM-DDThh:mm:ssZ`
+    /// * Precision = `3`: `YYYY-MM-DDThh:mm:ss.nnnZ`
     ///
-    /// If specified, `precision` denotes the number decimal places included after the
+    /// If `precision` denotes the number decimal places included after the
     /// seconds, limited to 9 decimal places (nanosecond precision).
-    /// If `None`, no decimal component is included.
+    /// If `0`, no decimal component is included.
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
-    pub fn as_iso_datetime(&self, precision: Option<usize>) -> String {
+    #[cfg(feature = "alloc")]
+    pub fn as_iso_datetime(&self, precision: usize) -> String {
         self.date.as_iso_date() + &self.tod.as_iso_tod(precision)
     }
+
+    /// Write an ISO datetime to a buffer in the format:
+    /// * Precision = `0`: `YYYY-MM-DDThh:mm:ssZ`
+    /// * Precision = `3`: `YYYY-MM-DDThh:mm:ss.nnnZ`
+    ///
+    /// The buffer should have a minimum length as given by [UTCDatetime::iso_datetime_len].
+    ///
+    /// A buffer of insufficient length will error ([UTCDatetimeError::InsufficientStrLen]).
+    ///
+    /// Returns number of UTF8 characters (bytes) written
+    ///
+    /// Conforms to ISO 8601:
+    /// <https://www.w3.org/TR/NOTE-datetime>
+    pub fn write_iso_datetime(
+        &self,
+        buf: &mut [u8],
+        precision: usize,
+    ) -> Result<usize, UTCDatetimeError> {
+        let write_len = Self::iso_datetime_len(precision);
+        if write_len > buf.len() {
+            return Err(UTCDatetimeError::InsufficientStrLen(buf.len(), write_len));
+        }
+        let mut writer = StrWriter::new(&mut buf[..write_len]);
+        self.date._write_iso_date_trunc(&mut writer);
+        self.tod._write_iso_tod_trunc(&mut writer);
+        Ok(writer.written)
+    }
+
+    /// Calculate the number of characters in an ISO datetime str
+    #[inline]
+    pub const fn iso_datetime_len(precision: usize) -> usize {
+        UTCTimeOfDay::iso_tod_len(precision) + UTCDate::ISO_DATE_LEN
+    }
 }
 
 impl UTCTransformations for UTCDatetime {
@@ -371,3 +449,109 @@ impl From<Duration> for UTCDatetime {
         Self::from_duration(duration)
     }
 }
+
+/// Error type for UTCDatetime methods
+#[derive(Debug, Clone)]
+pub enum UTCDatetimeError {
+    /// Error within UTC Date
+    UTCDate(UTCDateError),
+    /// Error within UTC Time of Day
+    UTCTimeOfDay(UTCTimeOfDayError),
+    /// Error raised due to insufficient length of input ISO datetime str
+    InsufficientStrLen(usize, usize),
+}
+
+impl Display for UTCDatetimeError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+        match self {
+            Self::UTCDate(e) => e.fmt(f),
+            Self::UTCTimeOfDay(e) => e.fmt(f),
+            Self::InsufficientStrLen(l, m) => {
+                write!(f, "insufficient ISO datetime str len ({l}), {m} required")
+            }
+        }
+    }
+}
+
+#[cfg(any(feature = "std", feature = "nightly"))]
+impl Error for UTCDatetimeError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            Self::UTCDate(e) => e.source(),
+            Self::UTCTimeOfDay(e) => e.source(),
+            _ => None,
+        }
+    }
+}
+
+impl From<UTCDateError> for UTCDatetimeError {
+    fn from(value: UTCDateError) -> Self {
+        Self::UTCDate(value)
+    }
+}
+
+impl From<UTCTimeOfDayError> for UTCDatetimeError {
+    fn from(value: UTCTimeOfDayError) -> Self {
+        Self::UTCTimeOfDay(value)
+    }
+}
+
+/// UTC Datetime crate level error type
+#[derive(Debug, Clone)]
+pub enum UTCError {
+    /// Error within UTC Date
+    UTCDate(UTCDateError),
+    /// Error within UTC Time of Day
+    UTCTimeOfDay(UTCTimeOfDayError),
+    /// Error within UTC Day
+    UTCDay(UTCDayErrOutOfRange),
+    /// Error within UTC Datetime
+    UTCDatetime(UTCDatetimeError),
+}
+
+impl Display for UTCError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+        match self {
+            Self::UTCDate(e) => e.fmt(f),
+            Self::UTCTimeOfDay(e) => e.fmt(f),
+            Self::UTCDay(e) => e.fmt(f),
+            Self::UTCDatetime(e) => e.fmt(f),
+        }
+    }
+}
+
+#[cfg(any(feature = "std", feature = "nightly"))]
+impl Error for UTCError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            Self::UTCDate(e) => e.source(),
+            Self::UTCTimeOfDay(e) => e.source(),
+            Self::UTCDay(e) => e.source(),
+            Self::UTCDatetime(e) => e.source(),
+        }
+    }
+}
+
+impl From<UTCDateError> for UTCError {
+    fn from(value: UTCDateError) -> Self {
+        Self::UTCDate(value)
+    }
+}
+
+impl From<UTCTimeOfDayError> for UTCError {
+    fn from(value: UTCTimeOfDayError) -> Self {
+        Self::UTCTimeOfDay(value)
+    }
+}
+
+impl From<UTCDayErrOutOfRange> for UTCError {
+    fn from(value: UTCDayErrOutOfRange) -> Self {
+        Self::UTCDay(value)
+    }
+}
+
+impl From<UTCDatetimeError> for UTCError {
+    fn from(value: UTCDatetimeError) -> Self {
+        Self::UTCDatetime(value)
+    }
+}
diff --git a/src/time.rs b/src/time.rs
index 5aa91b4..da15d2d 100644
--- a/src/time.rs
+++ b/src/time.rs
@@ -2,18 +2,24 @@
 //!
 //! Implements core time concepts via UTC Timestamps, UTC Days and UTC Time-of-Days.
 
-use core::{
-    fmt::{Display, Formatter},
-    ops::*,
-    time::Duration,
-};
+use crate::constants::*;
+use crate::util::StrWriter;
+use core::fmt::{Display, Formatter, Write};
+use core::num::ParseIntError;
+use core::ops::*;
+use core::time::Duration;
 
-#[cfg(feature = "std")]
-use std::time::SystemTime;
+#[cfg(feature = "alloc")]
+use alloc::{format, string::String};
 
-use anyhow::{bail, Result};
+#[cfg(feature = "std")]
+use std::time::{SystemTime, SystemTimeError};
 
-use crate::constants::*;
+// TODO <https://github.com/rust-lang/rust/issues/103765>
+#[cfg(feature = "nightly")]
+use core::error::Error;
+#[cfg(all(feature = "std", not(feature = "nightly")))]
+use std::error::Error;
 
 /// UTC Timestamp.
 ///
@@ -59,6 +65,7 @@ use crate::constants::*;
 /// let utc_timestamp_minus_1s = utc_timestamp.saturating_sub_secs(1);
 /// ```
 ///
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
 pub struct UTCTimestamp(Duration);
 
@@ -90,7 +97,7 @@ impl UTCTimestamp {
 
     /// Try to create a UTC Timestamp from the local system time.
     #[cfg(feature = "std")]
-    pub fn try_from_system_time() -> Result<Self> {
+    pub fn try_from_system_time() -> Result<Self, SystemTimeError> {
         let duration = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
         Ok(UTCTimestamp(duration))
     }
@@ -119,6 +126,7 @@ impl UTCTimestamp {
     pub const fn as_tod(&self) -> UTCTimeOfDay {
         let ns = ((self.0.as_secs() % SECONDS_PER_DAY) * NANOS_PER_SECOND)
             + (self.0.subsec_nanos() as u64);
+        // SAFETY: nanos is within NANOS_PER_DAY
         unsafe { UTCTimeOfDay::from_nanos_unchecked(ns) }
     }
 
@@ -585,7 +593,7 @@ where
 
     /// Create from local system time
     #[cfg(feature = "std")]
-    fn try_from_system_time() -> Result<Self> {
+    fn try_from_system_time() -> Result<Self, SystemTimeError> {
         let timestamp = UTCTimestamp::try_from_system_time()?;
         Ok(Self::from_timestamp(timestamp))
     }
@@ -620,6 +628,7 @@ where
 /// ## Safety
 /// Unchecked methods are provided for use in hot paths requiring high levels of optimisation.
 /// These methods assume valid input.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
 pub struct UTCDay(u64);
 
@@ -644,10 +653,10 @@ impl UTCDay {
     }
 
     /// Try create UTC Day from integer.
-    pub fn try_from_u64(u: u64) -> Result<Self> {
+    pub fn try_from_u64(u: u64) -> Result<Self, UTCDayErrOutOfRange> {
         let day = unsafe { Self::from_u64_unchecked(u) };
         if day > Self::MAX {
-            bail!("UTC Day exceeding maximum: {}", day.to_u64());
+            return Err(UTCDayErrOutOfRange(day.0));
         }
         Ok(day)
     }
@@ -777,6 +786,19 @@ impl UTCDay {
     }
 }
 
+/// Error type for UTCDay out of range
+#[derive(Debug, Clone)]
+pub struct UTCDayErrOutOfRange(u64);
+
+impl Display for UTCDayErrOutOfRange {
+    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+        write!(f, "UTC day ({}) exceeding maximum", self.0)
+    }
+}
+
+#[cfg(any(feature = "std", feature = "nightly"))]
+impl Error for UTCDayErrOutOfRange {}
+
 impl UTCTransformations for UTCDay {
     #[inline]
     fn from_secs(secs: u64) -> Self {
@@ -927,9 +949,9 @@ impl DivAssign<u64> for UTCDay {
 }
 
 impl TryFrom<u64> for UTCDay {
-    type Error = anyhow::Error;
+    type Error = UTCDayErrOutOfRange;
 
-    fn try_from(value: u64) -> core::result::Result<Self, Self::Error> {
+    fn try_from(value: u64) -> Result<Self, Self::Error> {
         Self::try_from_u64(value)
     }
 }
@@ -972,15 +994,20 @@ impl From<UTCTimestamp> for UTCDay {
 /// // Parse a UTC Time of Day from an ISO 8601 time string `(Thh:mm:ssZ)`
 /// let utc_tod = UTCTimeOfDay::try_from_iso_tod("T10:18:08.903Z").unwrap();
 /// // Get a time of day string formatted according to ISO 8601 `(Thh:mm:ssZ)`
-/// // Not available for #![no_std]
-/// let precision = Some(6);
-/// let iso_tod = utc_tod.as_iso_tod(precision);
+/// const PRECISION_MICROS: usize = 6;
+/// let iso_tod = utc_tod.as_iso_tod(PRECISION_MICROS);
 /// assert_eq!(iso_tod, "T10:18:08.903000Z");
+/// // Write ISO 8601 time of day str to a stack buffer
+/// let mut buf = [0; UTCTimeOfDay::iso_tod_len(PRECISION_MICROS)];
+/// let _bytes_written = utc_tod.write_iso_tod(&mut buf, PRECISION_MICROS).unwrap();
+/// let iso_tod_str = core::str::from_utf8(&buf).unwrap();
+/// assert_eq!(iso_tod_str, "T10:18:08.903000Z");
 /// ```
 ///
 /// ## Safety
 /// Unchecked methods are provided for use in hot paths requiring high levels of optimisation.
 /// These methods assume valid input.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
 pub struct UTCTimeOfDay(u64);
 
@@ -1007,6 +1034,12 @@ impl UTCTimeOfDay {
     /// Equal to the number of nanoseconds in a day.
     pub const MAX: Self = Self(NANOS_PER_DAY - 1);
 
+    /// The minimum length of an ISO time (in UTF8 characters)
+    pub const MIN_ISO_TOD_LEN: usize = 10;
+
+    /// The maximum supported subsecond precision of an ISO time
+    pub const MAX_ISO_TOD_PRECISION: usize = 9;
+
     /// Unchecked method to create time of day from nanoseconds
     ///
     /// ### Safety
@@ -1065,37 +1098,41 @@ impl UTCTimeOfDay {
     }
 
     /// Try to create UTC time of day from nanoseconds
-    pub fn try_from_nanos(nanos: u64) -> Result<Self> {
+    pub fn try_from_nanos(nanos: u64) -> Result<Self, UTCTimeOfDayError> {
+        // SAFETY: we immediately check that nanos was within NANOS_PER_DAY (tod does not exceed UTCTimeOfDay::MAX)
         let tod = unsafe { Self::from_nanos_unchecked(nanos) };
         if tod > Self::MAX {
-            bail!("Nanoseconds not within a day! (nanos: {})", nanos);
+            return Err(UTCTimeOfDayError::ExcessNanos(nanos));
         }
         Ok(tod)
     }
 
     /// Try to create UTC time of day from microseconds
-    pub fn try_from_micros(micros: u64) -> Result<Self> {
+    pub fn try_from_micros(micros: u64) -> Result<Self, UTCTimeOfDayError> {
+        // SAFETY: we immediately check that micros was within MICROS_PER_DAY (tod does not exceed UTCTimeOfDay::MAX)
         let tod = unsafe { Self::from_micros_unchecked(micros) };
         if tod > Self::MAX {
-            bail!("Microseconds not within a day! (micros: {})", micros);
+            return Err(UTCTimeOfDayError::ExcessMicros(micros));
         }
         Ok(tod)
     }
 
     /// Try to create UTC time of day from milliseconds
-    pub fn try_from_millis(millis: u32) -> Result<Self> {
+    pub fn try_from_millis(millis: u32) -> Result<Self, UTCTimeOfDayError> {
+        // SAFETY: we immediately check that millis was within MILLIS_PER_DAY (tod does not exceed UTCTimeOfDay::MAX)
         let tod = unsafe { Self::from_millis_unchecked(millis) };
         if tod > Self::MAX {
-            bail!("Milliseconds not within a day! (millis: {})", millis);
+            return Err(UTCTimeOfDayError::ExcessMillis(millis));
         }
         Ok(tod)
     }
 
     /// Try to create UTC time of day from seconds
-    pub fn try_from_secs(secs: u32) -> Result<Self> {
+    pub fn try_from_secs(secs: u32) -> Result<Self, UTCTimeOfDayError> {
+        // SAFETY: we immediately check that secs was within SECONDS_PER_DAY (tod does not exceed UTCTimeOfDay::MAX)
         let tod = unsafe { Self::from_secs_unchecked(secs) };
         if tod > Self::MAX {
-            bail!("Seconds not within a day! (secs: {})", secs);
+            return Err(UTCTimeOfDayError::ExcessSeconds(secs));
         }
         Ok(tod)
     }
@@ -1104,7 +1141,12 @@ impl UTCTimeOfDay {
     ///
     /// Inputs are not limited by divisions. eg. 61 minutes is valid input, 61 seconds, etc.
     /// The time described must not exceed the number of nanoseconds in a day.
-    pub fn try_from_hhmmss(hrs: u8, mins: u8, secs: u8, subsec_ns: u32) -> Result<Self> {
+    pub fn try_from_hhmmss(
+        hrs: u8,
+        mins: u8,
+        secs: u8,
+        subsec_ns: u32,
+    ) -> Result<Self, UTCTimeOfDayError> {
         Self::try_from_nanos(Self::_ns_from_hhmmss(hrs, mins, secs, subsec_ns))
     }
 
@@ -1159,61 +1201,156 @@ impl UTCTimeOfDay {
         timestamp.as_tod()
     }
 
-    /// Try parse time-of-day from string in the format:
+    /// Try parse time-of-day from an ISO str in the format:
     /// * `Thh:mm:ssZ`
     /// * `Thh:mm:ss.nnnZ` (up to 9 decimal places)
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
-    pub fn try_from_iso_tod(iso: &str) -> Result<Self> {
+    pub fn try_from_iso_tod(iso: &str) -> Result<Self, UTCTimeOfDayError> {
+        let len = iso.len();
+        if len < Self::MIN_ISO_TOD_LEN {
+            return Err(UTCTimeOfDayError::InsufficientStrLen(
+                len,
+                Self::MIN_ISO_TOD_LEN,
+            ));
+        }
         let (hour_str, rem) = iso[1..].split_at(2); // remainder = ":mm:ss.nnnZ"
         let (minute_str, rem) = rem[1..].split_at(2); // remainder = ":ss.nnnZ"
         let (second_str, rem) = rem[1..].split_at(2); // remainder = ".nnnZ"
-
         let hrs: u8 = hour_str.parse()?;
         let mins: u8 = minute_str.parse()?;
         let secs: u8 = second_str.parse()?;
-
+        // calculate subseconds
         let rem_len = rem.len();
         let subsec_ns: u32 = if rem_len > 1 {
             let subsec_str = &rem[1..(rem_len - 1)]; // "nnn"
             let precision: u32 = subsec_str.len() as u32;
-            if precision > 9 {
-                bail!(
-                    "Cannot parse ISO time-of-day: Precision ({}) exceeds maximum of 9",
-                    precision
-                );
+            if precision > Self::MAX_ISO_TOD_PRECISION as u32 {
+                return Err(UTCTimeOfDayError::ExcessPrecision(precision));
             }
             if precision == 0 {
                 0
             } else {
                 let subsec: u32 = subsec_str.parse()?;
-                subsec * 10u32.pow(9 - precision)
+                subsec * 10u32.pow(Self::MAX_ISO_TOD_PRECISION as u32 - precision)
             }
         } else {
             0
         };
-
         Self::try_from_hhmmss(hrs, mins, secs, subsec_ns)
     }
 
     /// Return time-of-day as a string in the format:
-    /// * Precision = `None`: `Thh:mm:ssZ`
-    /// * Precision = `Some(3)`: `Thh:mm:ss.nnnZ`
+    /// * Precision = `0`: `Thh:mm:ssZ`
+    /// * Precision = `3`: `Thh:mm:ss.nnnZ`
     ///
     /// Conforms to ISO 8601:
     /// <https://www.w3.org/TR/NOTE-datetime>
-    #[cfg(feature = "std")]
-    pub fn as_iso_tod(&self, precision: Option<usize>) -> String {
+    #[cfg(feature = "alloc")]
+    pub fn as_iso_tod(&self, precision: usize) -> String {
+        let len = Self::iso_tod_len(precision);
         let mut s = format!("{self}");
-        let len = if let Some(p) = precision {
-            10 + p.min(9)
-        } else {
-            9
-        };
-        s.truncate(len);
+        s.truncate(len - 1);
         s.push('Z');
         s
     }
+
+    /// Internal truncated buffer write
+    #[inline]
+    pub(crate) fn _write_iso_tod_trunc(&self, w: &mut StrWriter) {
+        // unwrap infallible
+        write!(w, "{self}").unwrap();
+        w.buf[w.written - 1] = b'Z';
+    }
+
+    /// Write time-of-day to a buffer in the format:
+    /// * Precision = `0`: `Thh:mm:ssZ`
+    /// * Precision = `3`: `Thh:mm:ss.nnnZ`
+    ///
+    /// The buffer should have a minimum length as given by [UTCTimeOfDay::iso_tod_len].
+    ///
+    /// A buffer of insufficient length will error ([UTCTimeOfDayError::InsufficientStrLen]).
+    ///
+    /// Returns number of UTF8 characters (bytes) written
+    ///
+    /// Conforms to ISO 8601:
+    /// <https://www.w3.org/TR/NOTE-datetime>
+    pub fn write_iso_tod(
+        &self,
+        buf: &mut [u8],
+        precision: usize,
+    ) -> Result<usize, UTCTimeOfDayError> {
+        let write_len = Self::iso_tod_len(precision);
+        if write_len > buf.len() {
+            return Err(UTCTimeOfDayError::InsufficientStrLen(buf.len(), write_len));
+        }
+        let mut writer = StrWriter::new(&mut buf[..write_len]);
+        self._write_iso_tod_trunc(&mut writer);
+        Ok(writer.written)
+    }
+
+    /// Calculate the number of characters in an ISO time-of-day str
+    #[inline]
+    pub const fn iso_tod_len(precision: usize) -> usize {
+        if precision == 0 {
+            Self::MIN_ISO_TOD_LEN
+        } else if precision < Self::MAX_ISO_TOD_PRECISION {
+            Self::MIN_ISO_TOD_LEN + precision + 1
+        } else {
+            // clamp to precision to max
+            Self::MIN_ISO_TOD_LEN + Self::MAX_ISO_TOD_PRECISION + 1
+        }
+    }
+}
+
+/// Error type for UTCTimeOfDay methods
+#[derive(Debug, Clone)]
+pub enum UTCTimeOfDayError {
+    /// Error raised parsing int to string
+    ParseErr(ParseIntError),
+    /// Error raised due to excessive ISO precision
+    ExcessPrecision(u32),
+    /// Error raised due to nanos exceeding nanos in a day
+    ExcessNanos(u64),
+    /// Error raised due to micros exceeding micros in a day
+    ExcessMicros(u64),
+    /// Error raised due to millis exceeding millis in a day
+    ExcessMillis(u32),
+    /// Error raised due to seconds exceeding seconds in a day
+    ExcessSeconds(u32),
+    /// Error raised due to insufficient length of input ISO time-of-day str
+    InsufficientStrLen(usize, usize),
+}
+
+impl Display for UTCTimeOfDayError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+        match self {
+            Self::ParseErr(e) => e.fmt(f),
+            Self::ExcessPrecision(p) => write!(f, "ISO precision ({p}) exceeds maximum of 9"),
+            Self::ExcessNanos(n) => write!(f, "nanoseconds ({n}) not within a day"),
+            Self::ExcessMicros(u) => write!(f, "microseconds ({u}) not within a day"),
+            Self::ExcessMillis(m) => write!(f, "milliseconds ({m}) not within a day"),
+            Self::ExcessSeconds(s) => write!(f, "seconds ({s}) not within a day"),
+            Self::InsufficientStrLen(l, m) => {
+                write!(f, "insufficient ISO time str len ({l}), {m} required")
+            }
+        }
+    }
+}
+
+#[cfg(any(feature = "std", feature = "nightly"))]
+impl Error for UTCTimeOfDayError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            Self::ParseErr(e) => e.source(),
+            _ => None,
+        }
+    }
+}
+
+impl From<ParseIntError> for UTCTimeOfDayError {
+    fn from(value: ParseIntError) -> Self {
+        Self::ParseErr(value)
+    }
 }
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..108582c
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,27 @@
+//! Internal utilities
+
+/// Utility for no-alloc str writing to a buffer via `core::fmt`
+pub struct StrWriter<'a> {
+    pub buf: &'a mut [u8],
+    pub written: usize,
+}
+
+impl<'a> StrWriter<'a> {
+    #[inline]
+    pub fn new(buf: &'a mut [u8]) -> Self {
+        let written = 0;
+        Self { buf, written }
+    }
+}
+
+impl<'a> core::fmt::Write for StrWriter<'a> {
+    fn write_str(&mut self, s: &str) -> core::fmt::Result {
+        let remaining = self.buf.len() - self.written;
+        let write_len = remaining.min(s.len());
+        let write_bytes = &s.as_bytes()[..write_len];
+        // infallible truncating write
+        self.buf[self.written..][..write_len].copy_from_slice(write_bytes);
+        self.written += write_len;
+        Ok(())
+    }
+}
diff --git a/tests/date.rs b/tests/date.rs
index 7f778de..a3e4ae4 100644
--- a/tests/date.rs
+++ b/tests/date.rs
@@ -1,15 +1,14 @@
 use std::collections::HashSet;
 
-use anyhow::Result;
-
 use utc_dt::{
     constants::{MICROS_PER_DAY, MILLIS_PER_DAY, NANOS_PER_DAY, SECONDS_PER_DAY},
     date::UTCDate,
     time::{UTCDay, UTCTimestamp, UTCTransformations},
+    UTCError,
 };
 
 #[test]
-fn test_date_from_components() -> Result<()> {
+fn test_date_from_components() {
     let test_cases = [
         (2023, 6, 14, true, false, 30),               // valid recent date
         (1970, 1, 1, true, false, 31),                // valid epoch date
@@ -38,12 +37,10 @@ fn test_date_from_components() -> Result<()> {
             }
         }
     }
-
-    Ok(())
 }
 
 #[test]
-fn test_date_from_day() -> Result<()> {
+fn test_date_from_day() -> Result<(), UTCError> {
     let test_cases = [
         (UTCDay::ZERO, 1970, 1, 1),
         (UTCDay::try_from_u64(30)?, 1970, 1, 31),
@@ -66,8 +63,7 @@ fn test_date_from_day() -> Result<()> {
 }
 
 #[test]
-#[cfg(feature = "std")]
-fn test_date_iso_conversions() -> Result<()> {
+fn test_date_iso_conversions() -> Result<(), UTCError> {
     let test_cases = [
         (2023, 6, 14, true, "2023-06-14"),   // valid recent date
         (1970, 1, 1, true, "1970-01-01"),    // valid epoch date
@@ -82,7 +78,9 @@ fn test_date_iso_conversions() -> Result<()> {
         (2023, 9, 0, false, "202a-09-00"),   // invalid date, year not integer
         (2023, 9, 0, false, "2023-0a-00"),   // invalid date, month not integer
         (2023, 9, 0, false, "2023-09-0a"),   // invalid date, day not integer
+        (2023, 9, 1, false, "2023-09-1"),    // invalid date, incorrect formatting
     ];
+    let mut buf = [0; UTCDate::ISO_DATE_LEN];
 
     for (year, month, day, case_is_valid, iso_date) in test_cases {
         match UTCDate::try_from_iso_date(iso_date) {
@@ -90,7 +88,14 @@ fn test_date_iso_conversions() -> Result<()> {
                 assert!(case_is_valid);
                 let date_from_comp = UTCDate::try_from_components(year, month, day)?;
                 assert_eq!(date_from_comp, date_from_iso);
+                #[cfg(feature = "alloc")]
                 assert_eq!(iso_date, date_from_comp.as_iso_date());
+                let written = date_from_comp.write_iso_date(&mut buf)?;
+                assert_eq!(iso_date.as_bytes(), &buf[..written]);
+                assert_eq!(iso_date, core::str::from_utf8(&buf[..written]).unwrap());
+                // test invalid buf len
+                let mut buf = [0; 1];
+                assert!(date_from_comp.write_iso_date(&mut buf).is_err());
             }
             Err(_) => {
                 assert!(!case_is_valid);
@@ -99,26 +104,28 @@ fn test_date_iso_conversions() -> Result<()> {
     }
 
     // test transform from system time
-    let date_from_system_time = UTCDate::try_from_system_time()?;
-    assert!(date_from_system_time >= UTCDate::MIN);
-    assert!(date_from_system_time <= UTCDate::MAX);
-    // test debug & display
-    println!("{:?}:{date_from_system_time}", date_from_system_time);
-    // test default, clone & copy, ord
-    assert_eq!(UTCDate::default().clone(), UTCDate::MIN);
-    let date_copy = date_from_system_time;
-    assert_eq!(date_copy, date_from_system_time);
-    assert_eq!(UTCDate::MIN, date_copy.min(UTCDate::MIN));
-    assert_eq!(UTCDate::MAX, date_copy.max(UTCDate::MAX));
-    // test limits
-    assert_eq!(UTCDate::from_day(UTCDay::MAX), UTCDate::MAX);
-    assert_eq!(UTCDate::from_day(UTCDay::ZERO), UTCDate::MIN);
-
+    #[cfg(feature = "std")]
+    {
+        let date_from_system_time = UTCDate::try_from_system_time().unwrap();
+        assert!(date_from_system_time >= UTCDate::MIN);
+        assert!(date_from_system_time <= UTCDate::MAX);
+        // test debug & display
+        println!("{:?}:{date_from_system_time}", date_from_system_time);
+        // test default, clone & copy, ord
+        assert_eq!(UTCDate::default().clone(), UTCDate::MIN);
+        let date_copy = date_from_system_time;
+        assert_eq!(date_copy, date_from_system_time);
+        assert_eq!(UTCDate::MIN, date_copy.min(UTCDate::MIN));
+        assert_eq!(UTCDate::MAX, date_copy.max(UTCDate::MAX));
+        // test limits
+        assert_eq!(UTCDate::from_day(UTCDay::MAX), UTCDate::MAX);
+        assert_eq!(UTCDate::from_day(UTCDay::ZERO), UTCDate::MIN);
+    }
     Ok(())
 }
 
 #[test]
-fn test_date_transformations() -> Result<()> {
+fn test_date_transformations() -> Result<(), UTCError> {
     let test_cases = [
         (UTCTimestamp::from_secs(0), 1970, 1, 1),
         (UTCTimestamp::from_secs(2592000), 1970, 1, 31),
@@ -184,3 +191,11 @@ fn test_date_transformations() -> Result<()> {
 
     Ok(())
 }
+
+#[cfg(feature = "serde")]
+#[test]
+fn test_date_serde() {
+    let date = UTCDate::from_day(UTCDay::try_from_u64(19959).unwrap());
+    let v = serde_json::to_value(&date).unwrap();
+    assert_eq!(date, serde_json::from_value(v).unwrap())
+}
diff --git a/tests/lib.rs b/tests/datetime.rs
similarity index 70%
rename from tests/lib.rs
rename to tests/datetime.rs
index 6ec3521..80618d1 100644
--- a/tests/lib.rs
+++ b/tests/datetime.rs
@@ -1,13 +1,11 @@
-use anyhow::Result;
-
 use utc_dt::{
     date::UTCDate,
     time::{UTCDay, UTCTimeOfDay, UTCTimestamp, UTCTransformations},
-    UTCDatetime,
+    UTCDatetime, UTCError,
 };
 
 #[test]
-fn test_datetime_from_raw_components() -> Result<()> {
+fn test_datetime_from_raw_components() -> Result<(), UTCError> {
     let test_cases = [
         (1970, 1, 1, 0, 0, 0, 0, 0, UTCDay::ZERO), // thu, 00:00:00.000
         (
@@ -35,7 +33,7 @@ fn test_datetime_from_raw_components() -> Result<()> {
 
     // test display & debug
     #[cfg(feature = "std")]
-    let datetime = UTCDatetime::try_from_system_time()?;
+    let datetime = UTCDatetime::try_from_system_time().unwrap();
     #[cfg(not(feature = "std"))]
     let datetime = UTCDatetime::from_millis(1686824288903);
     // test to/as components
@@ -43,7 +41,7 @@ fn test_datetime_from_raw_components() -> Result<()> {
     assert_eq!((date, tod), datetime.to_components());
     // test from timestamp
     #[cfg(feature = "std")]
-    let timestamp = UTCTimestamp::try_from_system_time()?;
+    let timestamp = UTCTimestamp::try_from_system_time().unwrap();
     #[cfg(not(feature = "std"))]
     let timestamp = UTCTimestamp::from_millis(1686824288903);
     let datetime_from_timestamp = UTCDatetime::from_timestamp(timestamp);
@@ -95,24 +93,23 @@ fn test_datetime_from_raw_components() -> Result<()> {
     Ok(())
 }
 
-#[cfg(feature = "std")]
 #[test]
-fn test_datetime_iso_conversions() -> Result<()> {
+fn test_datetime_iso_conversions() -> Result<(), UTCError> {
     let test_cases = [
-        (1970, 1, 1, 0, None, "1970-01-01T00:00:00Z"), // thu, 00:00:00
-        (1970, 1, 1, 0, Some(0), "1970-01-01T00:00:00.Z"), // thu, 00:00:00.
-        (1970, 1, 1, 0, Some(3), "1970-01-01T00:00:00.000Z"), // thu, 00:00:00.000
-        (1970, 1, 1, 0, Some(9), "1970-01-01T00:00:00.000000000Z"), // thu, 00:00:00.000000000
-        (1970, 1, 1, 0, Some(11), "1970-01-01T00:00:00.000000000Z"), // thu, 00:00:00.000000000
+        (1970, 1, 1, 0, 0, "1970-01-01T00:00:00Z"), // thu, 00:00:00
+        (1970, 1, 1, 0, 3, "1970-01-01T00:00:00.000Z"), // thu, 00:00:00.000
+        (1970, 1, 1, 0, 9, "1970-01-01T00:00:00.000000000Z"), // thu, 00:00:00.000000000
+        (1970, 1, 1, 0, 11, "1970-01-01T00:00:00.000000000Z"), // thu, 00:00:00.000000000
         (
             2023,
             6,
             14,
             33_609_648_000_000,
-            Some(3),
+            3,
             "2023-06-14T09:20:09.648Z",
         ), // wed, 09:20:09.648
     ];
+    let mut buf = [0; UTCDatetime::iso_datetime_len(9)];
 
     // run iso conversion test cases
     for (year, month, day, tod_ns, precision, iso_datetime) in test_cases {
@@ -120,20 +117,45 @@ fn test_datetime_iso_conversions() -> Result<()> {
         let tod = UTCTimeOfDay::try_from_nanos(tod_ns)?;
         let datetime_from_components = UTCDatetime::from_components(date, tod);
         let datetime_from_iso = UTCDatetime::try_from_iso_datetime(iso_datetime)?;
+        #[cfg(feature = "alloc")]
         assert_eq!(
             datetime_from_components.as_iso_datetime(precision),
             iso_datetime
         );
+        let written = datetime_from_components.write_iso_datetime(&mut buf, precision)?;
+        let iso_raw_str = core::str::from_utf8(&buf[..written]).unwrap();
+        assert_eq!(iso_raw_str.len(), UTCDatetime::iso_datetime_len(precision));
+        assert_eq!(iso_datetime.as_bytes(), &buf[..written]);
+        assert_eq!(iso_datetime, iso_raw_str);
         assert_eq!(datetime_from_iso, datetime_from_components);
+        // test maybe-invalid buf len
+        let mut buf = [0; 3];
+        let result = datetime_from_components.write_iso_datetime(&mut buf, precision);
+        if buf.len() < UTCDatetime::iso_datetime_len(precision) {
+            assert!(result.is_err())
+        } else {
+            assert!(result.is_ok())
+        }
     }
 
     // test invalid iso dates
     assert!(UTCDatetime::try_from_iso_datetime("197a-01-01T00:00:00Z").is_err());
     assert!(UTCDatetime::try_from_iso_datetime("1970-01-01T00:a0:00Z").is_err());
+    assert!(UTCDatetime::try_from_iso_datetime("1970-01-01T00:a0").is_err());
 
     // test display & debug
-    let datetime = UTCDatetime::try_from_system_time()?;
-    println!("{:?}:{datetime}", datetime);
-
+    #[cfg(feature = "std")]
+    {
+        let datetime = UTCDatetime::try_from_system_time().unwrap();
+        println!("{:?}:{datetime}", datetime);
+    }
     Ok(())
 }
+
+#[cfg(feature = "serde")]
+#[test]
+fn test_datetime_serde() {
+    let datetime = UTCDatetime::from_secs(1724493234);
+    let v = serde_json::to_value(&datetime).unwrap();
+    assert_eq!(datetime, serde_json::from_value(v).unwrap());
+}
diff --git a/tests/error.rs b/tests/error.rs
new file mode 100644
index 0000000..7889979
--- /dev/null
+++ b/tests/error.rs
@@ -0,0 +1,61 @@
+use core::fmt::Display;
+use utc_dt::date::{UTCDate, UTCDateError};
+use utc_dt::time::{UTCDay, UTCTimeOfDayError};
+use utc_dt::{UTCDatetimeError, UTCError};
+
+#[cfg(feature = "std")]
+fn check_errors<T: std::error::Error + Display>(errors: &[T]) {
+    for error in errors {
+        print!("Error Display test: {error}");
+        if let Some(source) = error.source() {
+            print!(", caused by {source}");
+        }
+        print!("\n");
+    }
+}
+
+#[cfg(not(feature = "std"))]
+fn check_errors<T: Display>(errors: &[T]) {
+    for error in errors {
+        print!("Error Display test: {error}");
+        print!("\n");
+    }
+}
+
+#[test]
+fn test_errors() {
+    let utc_date_errors = [
+        UTCDateError::ParseErr("a".parse::<u32>().unwrap_err()),
+        UTCDateError::DateOutOfRange(UTCDate::MAX),
+        UTCDateError::DayOutOfRange(UTCDate::MIN),
+        UTCDateError::InvalidStrLen(30),
+        UTCDateError::MonthOutOfRange(13),
+        UTCDateError::YearOutOfRange(1969),
+    ];
+    check_errors(&utc_date_errors);
+    let utc_tod_errors = [
+        UTCTimeOfDayError::ParseErr("a".parse::<u32>().unwrap_err()),
+        UTCTimeOfDayError::ExcessMicros(0),
+        UTCTimeOfDayError::ExcessMillis(0),
+        UTCTimeOfDayError::ExcessNanos(0),
+        UTCTimeOfDayError::ExcessSeconds(0),
+        UTCTimeOfDayError::ExcessPrecision(0),
+        UTCTimeOfDayError::InsufficientStrLen(10, 20),
+    ];
+    check_errors(&utc_tod_errors);
+    let utc_day_error = [UTCDay::try_from_u64(213_503_982_334_602).unwrap_err()];
+    check_errors(&utc_day_error);
+    let utc_datetime_errors = [
+        utc_date_errors[0].clone().into(),
+        utc_tod_errors[0].clone().into(),
+        UTCDatetimeError::InsufficientStrLen(10, 20),
+    ];
+    check_errors(&utc_datetime_errors);
+    let utc_errors: [UTCError; 4] = [
+        utc_date_errors[1].clone().into(),
+        utc_tod_errors[1].clone().into(),
+        utc_day_error[0].clone().into(),
+        utc_datetime_errors[0].clone().into(),
+    ];
+    check_errors(&utc_errors.clone());
+}
diff --git a/tests/time.rs b/tests/time.rs
index dcec299..5681d84 100644
--- a/tests/time.rs
+++ b/tests/time.rs
@@ -1,15 +1,14 @@
 use core::time::Duration;
 use std::collections::HashSet;
 
-use anyhow::Result;
-
 use utc_dt::{
     constants::{MICROS_PER_DAY, MILLIS_PER_DAY, NANOS_PER_DAY, NANOS_PER_SECOND, SECONDS_PER_DAY},
     time::{UTCDay, UTCTimeOfDay, UTCTimestamp, UTCTransformations},
+    UTCError,
 };
 
 #[test]
-fn test_utc_timestamp() -> Result<()> {
+fn test_utc_timestamp() -> Result<(), UTCError> {
     let test_cases = [
         (
             UTCTimestamp::from_nanos(0),
@@ -96,7 +95,7 @@ fn test_utc_timestamp() -> Result<()> {
 
     // test from system time
     #[cfg(feature = "std")]
-    let timestamp = UTCTimestamp::try_from_system_time()?;
+    let timestamp = UTCTimestamp::try_from_system_time().unwrap();
     #[cfg(not(feature = "std"))]
     let timestamp = UTCTimestamp::from_millis(1686824288903);
     assert!(timestamp <= UTCTimestamp::MAX);
@@ -198,10 +197,10 @@ fn test_utc_timestamp() -> Result<()> {
 }
 
 #[test]
-fn test_utc_day() -> Result<()> {
+fn test_utc_day() -> Result<(), UTCError> {
     // test from system time
     #[cfg(feature = "std")]
-    let utc_day = UTCDay::try_from_system_time()?;
+    let utc_day = UTCDay::try_from_system_time().unwrap();
     #[cfg(not(feature = "std"))]
     let utc_day = UTCDay::from_millis(1686824288903);
     assert!(utc_day <= UTCDay::MAX);
@@ -290,10 +289,10 @@ fn test_utc_day() -> Result<()> {
 }
 
 #[test]
-fn test_utc_tod() -> Result<()> {
+fn test_utc_tod() -> Result<(), UTCError> {
     // test from system time
     #[cfg(feature = "std")]
-    let timestamp = UTCTimestamp::try_from_system_time()?;
+    let timestamp = UTCTimestamp::try_from_system_time().unwrap();
     #[cfg(not(feature = "std"))]
     let timestamp = UTCTimestamp::from_millis(1686824288903);
     let tod_from_timestamp = UTCTimeOfDay::from_timestamp(timestamp);
@@ -311,25 +310,53 @@ fn test_utc_tod() -> Result<()> {
     assert!(UTCTimeOfDay::try_from_hhmmss(23, 59, 59, (NANOS_PER_SECOND - 1) as u32).is_ok());
     assert!(UTCTimeOfDay::try_from_hhmmss(u8::MAX, u8::MAX, u8::MAX, u32::MAX).is_err());
     // test iso conversions
-    #[cfg(feature = "std")]
-    {
-        let iso_from_tod = tod_from_timestamp.as_iso_tod(Some(9));
-        let tod_from_iso = UTCTimeOfDay::try_from_iso_tod(&iso_from_tod)?;
-        assert_eq!(tod_from_iso, tod_from_timestamp);
-        assert_eq!(
-            UTCTimeOfDay::try_from_iso_tod("T00:00:00Z")?,
-            UTCTimeOfDay::ZERO
-        );
-        assert_eq!(
-            UTCTimeOfDay::try_from_iso_tod("T23:59:59.999999999Z")?,
-            UTCTimeOfDay::MAX
-        );
-        assert!(UTCTimeOfDay::try_from_iso_tod("Taa:59:59.999999999Z").is_err()); // invalid hour
-        assert!(UTCTimeOfDay::try_from_iso_tod("T23:aa:59.999999999Z").is_err()); // invalid mins
-        assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:aa.999999999Z").is_err()); // invalid secs
-        assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:59.a99999999Z").is_err()); // invalid subsec
-        assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:59.9999999990Z").is_err());
-        // invalid precision
+    #[cfg(feature = "alloc")]
+    let iso_from_tod = tod_from_timestamp.as_iso_tod(9);
+    #[cfg(not(feature = "alloc"))]
+    let mut buf = [0; UTCTimeOfDay::iso_tod_len(9)];
+    #[cfg(not(feature = "alloc"))]
+    let iso_from_tod = {
+        let _ = tod_from_timestamp.write_iso_tod(&mut buf, 9)?;
+        core::str::from_utf8(&buf).unwrap()
+    };
+    let tod_from_iso = UTCTimeOfDay::try_from_iso_tod(&iso_from_tod)?;
+    assert_eq!(tod_from_iso, tod_from_timestamp);
+    assert_eq!(
+        UTCTimeOfDay::try_from_iso_tod("T00:00:00Z")?,
+        UTCTimeOfDay::ZERO
+    );
+    assert_eq!(
+        UTCTimeOfDay::try_from_iso_tod("T00:00:00.Z")?,
+        UTCTimeOfDay::ZERO
+    );
+    assert_eq!(
+        UTCTimeOfDay::try_from_iso_tod("T23:59:59.999999999Z")?,
+        UTCTimeOfDay::MAX
+    );
+    assert!(UTCTimeOfDay::try_from_iso_tod("Taa:59:59.999999999Z").is_err()); // invalid hour
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:aa:59.999999999Z").is_err()); // invalid mins
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:aa.999999999Z").is_err()); // invalid secs
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:59.a99999999Z").is_err()); // invalid subsec
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:59.9999999990Z").is_err());
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23::59.9999999990Z").is_err());
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:59.9999999990Z").is_err());
+    assert!(UTCTimeOfDay::try_from_iso_tod("T23:59:59").is_err());
+    // test no-alloc iso conversions
+    let mut buf = [0; UTCTimeOfDay::iso_tod_len(9)];
+    for precision in 0..13 {
+        let written = tod_from_timestamp.write_iso_tod(&mut buf, precision)?;
+        let iso_raw_str = core::str::from_utf8(&buf[..written]).unwrap();
+        assert_eq!(iso_raw_str.len(), UTCTimeOfDay::iso_tod_len(precision));
+        #[cfg(feature = "alloc")]
+        assert_eq!(tod_from_timestamp.as_iso_tod(precision), iso_raw_str);
+        // test maybe-invalid buf len
+        let mut buf = [0; 5];
+        let result = tod_from_timestamp.write_iso_tod(&mut buf, precision);
+        if buf.len() < UTCTimeOfDay::iso_tod_len(precision) {
+            assert!(result.is_err())
+        } else {
+            assert!(result.is_ok())
+        }
     }
 
     // test unit conversions
@@ -367,3 +394,19 @@ fn test_utc_tod() -> Result<()> {
     );
     Ok(())
 }
+
+#[cfg(feature = "serde")]
+#[test]
+fn test_time_serde() {
+    let timestamp = UTCTimestamp::from_day(UTCDay::try_from_u64(19959).unwrap());
+    let v = serde_json::to_value(&timestamp).unwrap();
+    assert_eq!(timestamp, serde_json::from_value(v).unwrap());
+
+    let day = UTCDay::try_from_u64(19959).unwrap();
+    let v = serde_json::to_value(&day).unwrap();
+    assert_eq!(day, serde_json::from_value(v).unwrap());
+
+    let time_of_day = UTCTimeOfDay::try_from_hhmmss(17, 50, 23, 0).unwrap();
+    let v = serde_json::to_value(&time_of_day).unwrap();
+    assert_eq!(time_of_day, serde_json::from_value(v).unwrap());
+}