diff --git a/src/lib.rs b/src/lib.rs
index 6cd57b8755a2458845072d535b61f37b4eea19de..9b5b4f13c37d638081ca4588dc03a0fc068df168 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,6 +8,8 @@ pub mod cli;
 pub mod config;
 /// Range dictionary data structure
 pub mod rangedict;
+/// Range set data structure
+pub mod rangeset;
 /// Ephemeral working directories.
 pub mod rundir;
 /// String utilities.
diff --git a/src/rangeset.rs b/src/rangeset.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f6e8ee395af7f47881671f911f516e2d2e451067
--- /dev/null
+++ b/src/rangeset.rs
@@ -0,0 +1,110 @@
+use std::collections::BTreeMap;
+
+use thiserror::Error;
+
+/// Error type for [`RangeSet`]
+#[derive(Error, Debug, PartialEq)]
+pub enum RangeSetError {
+    /// Used when an invalid range is provided (e.g. End is before start).
+    #[error("Invalid range provided")]
+    InvalidRange,
+    /// Used when an insertion would result in an overlap.
+    #[error("Overlapping ranges")]
+    RangeOverlap,
+}
+
+/// A set of non-overlapping ranges.
+#[derive(Debug)]
+pub struct RangeSet<RK: Ord + Copy> {
+    ranges: BTreeMap<RK, RK>, // Maps start -> end
+}
+
+impl<RK: Ord + Copy> RangeSet<RK> {
+    /// Creates a new empty `RangeSet`.
+    pub fn new() -> Self {
+        Self {
+            ranges: BTreeMap::new(),
+        }
+    }
+
+    /// Inserts a new range `[start, end)`, ensuring no overlaps.
+    pub fn insert(&mut self, start: RK, end: RK) -> Result<(), RangeSetError> {
+        if start >= end {
+            return Err(RangeSetError::InvalidRange);
+        }
+
+        // Find adjacent or overlapping ranges
+        if let Some((&_prev_start, &prev_end)) = self.ranges.range(..=start).next_back() {
+            if prev_end >= start {
+                return Err(RangeSetError::RangeOverlap);
+            }
+        }
+        if let Some((&next_start, _)) = self.ranges.range(start..).next() {
+            if next_start < end {
+                return Err(RangeSetError::RangeOverlap);
+            }
+        }
+
+        self.ranges.insert(start, end);
+        Ok(())
+    }
+
+    /// Checks if a value exists in any range.
+    pub fn contains(&self, value: RK) -> bool {
+        if let Some((&_start, &end)) = self.ranges.range(..=value).next_back() {
+            return value < end;
+        }
+        false
+    }
+
+    /// Removes a range if it exists.
+    pub fn remove(&mut self, start: RK, end: RK) -> bool {
+        if let Some(range_end) = self.ranges.get(&start) {
+            if *range_end == end {
+                self.ranges.remove(&start);
+                return true;
+            }
+        }
+
+        false
+    }
+
+    /// Returns all stored ranges.
+    pub fn iter(&self) -> impl Iterator<Item = (RK, RK)> + '_ {
+        self.ranges.iter().map(|(&s, &e)| (s, e))
+    }
+}
+
+impl<RK: Copy + Ord> Default for RangeSet<RK> {
+    fn default() -> Self {
+        RangeSet::new()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::rangeset::{RangeSet, RangeSetError};
+
+    #[test]
+    fn test_insertions() {
+        let mut rs = RangeSet::new();
+
+        assert_eq!(rs.insert(10, 20), Ok(()));
+        assert_eq!(rs.insert(30, 40), Ok(()));
+        assert_eq!(rs.insert(15, 25), Err(RangeSetError::RangeOverlap));
+        assert_eq!(rs.contains(15), true);
+        assert_eq!(rs.contains(25), false);
+    }
+
+    #[test]
+    fn test_removal() {
+        let mut rs = RangeSet::new();
+
+        assert_eq!(rs.insert(10, 20), Ok(()));
+        assert_eq!(rs.insert(30, 40), Ok(()));
+        assert_eq!(rs.remove(10, 11), false);
+        assert_eq!(rs.remove(10, 20), true);
+        assert_eq!(rs.remove(30, 40), true);
+        assert_eq!(rs.remove(30, 40), false);
+    }
+}