Delete Operations
Delete rows from Arc with WHERE-clause predicates: enable delete in configuration, run a rewrite-based delete, and confirm the Parquet files were rewritten.
Arc supports deleting data using a rewrite-based approach that provides precise deletion with zero overhead on write and query operations.
Configuration Required
Delete operations must be explicitly enabled in configuration for safety.
Overview
Arc's delete operations provide:
- Precise Control: Delete specific rows using WHERE clauses
- Zero Runtime Overhead: No performance impact on writes or queries
- Physical Removal: Data is permanently removed by rewriting Parquet files
- Safety Mechanisms: Multiple safeguards prevent accidental deletion
- Dry Run Mode: Test operations before execution
How it works
Arc deletes rows by rewriting the Parquet files that hold them:
1. Flush buffered rows
Rows written shortly before the DELETE may still be in the ingest buffer.
Since v27.01.1 the DELETE first flushes that measurement's buffered rows and
waits for them to reach files, so every row written before the request is
deleted. While an earlier flush failure is outstanding (its rows wait for WAL
recovery), the DELETE answers 503 with Retry-After: 30 instead. A dry run
does not flush.
2. Find affected files
List the measurement's Parquet files and count the rows matching the WHERE clause in each, with DuckDB.
In a cluster with compaction commit on (every node runs Arc 2027.01.1 or
later), the DELETE first catches up with the cluster manifest, then scans the
files the manifest lists for the measurement, plus files this node is still
registering. A file in storage the manifest does not list, such as a
compaction output not committed yet, is left alone. A file the manifest lists
that is not on the node yet (a compaction output on its way) is waited for, up
to 30 seconds per scan; a file the manifest stops listing meanwhile is no
longer waited for. A file still missing answers 503 listing it in
unreachable_files, unless allow_unreachable is set, in which case the
DELETE runs without it and still lists it. A dry run does not wait and lists
the files it could not count.
3. Rewrite files
For each affected file:
- Write the rows that do not match,
SELECT * FROM read_parquet(file) WHERE NOT (<where>), to a new file with a fresh name (<name>_rewrite_<nanoseconds>.parquet) in the same partition directory - Publish the new file, then remove the old one (in a cluster, both in one manifest update)
A file whose rows all match is removed.
Crash safety
The old file stays in place until its replacement is published, so a crash leaves either the old file or the new one, never a partial file. Two DELETEs rewrite one at a time.
Compaction during a DELETE
In a cluster with compaction commit on, a DELETE publishes its rewrite of a
file only while the file is still registered with the content it read. If a
compaction replaced the file while the DELETE ran, the DELETE waits until the
compaction's output reaches the node (up to 30 seconds), scans again and
deletes from the output. After three rounds of files changing under it, it
answers 503 with success: false: the rows of the files listed in
files_processed are deleted; retry to finish.
Configuration
Delete operations must be explicitly enabled and configured.
Configuration file
Edit arc.toml:
[delete]
enabled = true
confirmation_threshold = 10000
max_rows_per_delete = 1000000Environment variables
export ARC_DELETE_ENABLED=true
export ARC_DELETE_CONFIRMATION_THRESHOLD=10000
export ARC_DELETE_MAX_ROWS_PER_DELETE=1000000Configuration parameters
enabled(boolean): Enable/disable delete functionality (default:false)confirmation_threshold(integer): Row count requiring explicit confirmation (default:10000)max_rows_per_delete(integer): Maximum rows allowed per operation (default:1000000)
API endpoints
Delete data
Execute a delete operation:
POST /api/v1/deleteRequest Body:
{
"database": "telegraf",
"measurement": "cpu",
"where": "host = 'server01' AND time < '2024-01-01'",
"dry_run": false,
"confirm": false
}Parameters:
database(string, required): Target database namemeasurement(string, required): Target measurement namewhere(string, required): SQL WHERE clause for deletiondry_run(boolean, optional): Test without deleting (default:false)confirm(boolean, optional): Confirm the operation; required for anything but a dry run (default:false)allow_unreachable(boolean, optional, clusters): run the DELETE although files the cluster manifest lists have not reached the primary yet; their rows are not deleted. Applies with stream takeover or compaction commit, to the first scan and to the scan after a compaction replaced a file (default:false)
Response:
{
"success": true,
"deleted_count": 15000,
"affected_files": 3,
"rewritten_files": 3,
"execution_time_ms": 1250,
"dry_run": false,
"files_processed": [
"cpu_20231215_100000_000000000.parquet",
"cpu_20231220_100000_000000000.parquet",
"cpu_20231221_100000_000000000.parquet"
]
}files_processedlists the names of the files rewritten or removed (on a dry run, the files that would be).failed_files(when present) lists files that could not be rewritten; the response is then207withsuccess: false, and the rows of those files are not deleted.unreachable_files(clusters) lists manifest files the primary did not have: skipped withallow_unreachable, or the reason for a503without it.
Get configuration
Retrieve current delete configuration:
GET /api/v1/delete/configResponse:
{
"enabled": true,
"confirmation_threshold": 10000,
"max_rows_per_delete": 1000000
}Safety mechanisms
1. WHERE clause required
Delete operations must include a WHERE clause to prevent accidental full-table deletion.
Intentional Full Delete:
{
"where": "1=1" // Explicitly delete all rows
}2. Confirmation threshold
Operations exceeding the configured threshold require explicit confirmation:
{
"where": "time < '2024-01-01'",
"confirm": true // Required if deleted_count > threshold
}Without Confirmation:
{
"error": "Operation would delete 15000 rows, exceeding threshold of 10000. Set confirm=true to proceed."
}3. Maximum rows limit
Hard cap prevents extremely large operations that could exhaust resources:
{
"error": "Operation would delete 2000000 rows, exceeding maximum of 1000000"
}4. Immutable rewrites
A rewrite is published under a new file name before the old file is removed: a crash leaves the old file or the new one, never a partial file.
Usage examples
Example 1: delete old data
import os
import requests
ARC_TOKEN = os.environ["ARC_TOKEN"]
# Delete data older than a specific date
response = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": "time < '2024-01-01'",
"confirm": True
}
)
print(f"Deleted {response.json()['deleted_count']} rows")
print(f"Execution time: {response.json()['execution_time_ms']}ms")Example 2: delete specific host data
import os
ARC_TOKEN = os.environ["ARC_TOKEN"]
# Delete data from a specific host
response = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": "host = 'server01' OR host = 'server02'",
"confirm": True
}
)Example 3: dry run first
import os
ARC_TOKEN = os.environ["ARC_TOKEN"]
# Always test with dry run before deleting
dry_run = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": "host = 'server01'",
"dry_run": True
}
)
print(f"Would delete {dry_run.json()['deleted_count']} rows")
print(f"Affected files: {dry_run.json()['affected_files']}")
# Review the files that would be affected
for name in dry_run.json()['files_processed']:
print(f" {name}")
# If satisfied, execute for real
if input("Proceed? (yes/no): ") == "yes":
result = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": "host = 'server01'",
"dry_run": False,
"confirm": True
}
)
print(f"Deleted {result.json()['deleted_count']} rows")Example 4: delete with confirmation
import os
ARC_TOKEN = os.environ["ARC_TOKEN"]
# Large delete requiring confirmation
response = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": "time < '2023-01-01'",
"confirm": True # Explicitly confirm large operation
}
)Example 5: complex WHERE clause
import os
ARC_TOKEN = os.environ["ARC_TOKEN"]
# Delete based on multiple conditions
response = requests.post(
"http://localhost:8000/api/v1/delete",
headers={"Authorization": f"Bearer {ARC_TOKEN}"},
json={
"database": "telegraf",
"measurement": "cpu",
"where": """
host IN ('server01', 'server02', 'server03')
AND time BETWEEN '2023-01-01' AND '2023-06-30'
AND usage_idle < 10
""",
"confirm": True
}
)Performance characteristics
Delete operations are computationally expensive but designed for infrequent use:
Execution times
Small Files (10MB):
- Read + Filter + Write: ~50-100ms per file
Medium Files (100MB):
- Read + Filter + Write: ~500ms-1s per file
Large Files (1GB):
- Read + Filter + Write: ~2-5s per file
Performance factors
- File Size: Larger files take longer to rewrite
- Selectivity: Fewer deleted rows = faster (less data movement)
- Storage I/O: Disk speed affects read/write performance
- Concurrent Load: Other operations may slow deletion
Best practices
1. Keep disabled by default
Only enable delete operations when needed:
[delete]
enabled = false # Enable only when necessary2. Always use dry run
Test operations before execution to verify scope:
# Step 1: Dry run
result = requests.post(..., json={"dry_run": True})
print(f"Would delete {result['deleted_count']} rows")
# Step 2: Review affected files
for file in result['files']:
print(f"{file['path']}: {file['action']}")
# Step 3: Execute if satisfied
result = requests.post(..., json={"dry_run": False, "confirm": True})3. Consider retention policies
For time-based deletion, use retention policies instead:
# Instead of manual deletes:
# requests.post("/api/v1/delete", json={"where": "time < '2024-01-01'"})
# Use retention policies:
requests.post("/api/v1/retention", json={
"retention_days": 90,
"buffer_days": 7
})4. Monitor execution times
Track deletion performance for capacity planning:
import time
start = time.time()
result = requests.post("/api/v1/delete", json={...})
elapsed = time.time() - start
print(f"Deleted {result['deleted_count']} rows in {elapsed:.2f}s")5. Batch large deletes
Break large deletions into smaller batches by time range:
from datetime import datetime, timedelta
# Instead of one large delete:
# WHERE time < '2023-01-01'
# Batch by month:
start = datetime(2022, 1, 1)
while start < datetime(2023, 1, 1):
end = start + timedelta(days=30)
requests.post("/api/v1/delete", json={
"where": f"time >= '{start.isoformat()}' AND time < '{end.isoformat()}'"
})
start = end6. Understand storage impact
Deletion rewrites files, which may temporarily increase storage usage:
# Before deletion: 100MB original file
# During deletion: 100MB original + 60MB temp file = 160MB
# After deletion: 60MB rewritten fileEnsure sufficient disk space for temporary files during operations.
Limitations
Not for frequent operations
Delete operations rewrite entire Parquet files, making them expensive. They are designed for infrequent, manual operations only.
Use Cases:
- Removing test data
- Deleting specific hosts/sensors
- One-time cleanup operations
Not Suitable For:
- Automated recurring deletions (use retention policies)
- High-frequency data cleanup
- Real-time data removal
Explicit WHERE required
Full-table deletion requires explicit WHERE 1=1:
# This will fail:
{"where": ""} # Error: WHERE clause required
# Explicit full delete:
{"where": "1=1", "confirm": True}Maximum row limits
Large deletions are subject to max_rows_per_delete configuration:
# Will fail if exceeds limit:
{"where": "time < '2020-01-01'"} # May exceed max_rows
# Solution: Batch by time range
{"where": "time >= '2023-01-01' AND time < '2023-02-01'"}One DELETE at a time
DELETEs rewrite one at a time; a second one waits for the first. Writes are not blocked.
Troubleshooting
Delete not enabled
Problem: delete.enabled is false (the default).
Solution:
[delete]
enabled = trueConfirmation required
Problem: Operation exceeds confirmation threshold.
Solution: Add confirm: true:
{"confirm": true}Exceeds maximum rows
Problem: Deletion would affect more rows than max_rows_per_delete.
Solutions:
- Batch the operation by time range
- Increase
max_rows_per_delete(carefully) - Use retention policies for large-scale cleanup
No rows deleted
Problem: deleted_count: 0 but expected deletions.
Solutions:
- Verify WHERE clause syntax matches data
- Check that data exists in the specified database/measurement
- Use dry run to inspect affected files
Slow execution
Problem: Delete operations take longer than expected.
Solutions:
- Check file sizes (large files take longer)
- Monitor disk I/O performance
- Batch operations during low-traffic periods
- Consider using retention policies for time-based cleanup
Related topics
- Retention Policies - Automated time-based deletion
- Continuous Queries - Downsample before deletion
- Compaction - File optimization for better performance
Retention Policies
Define retention windows per database or measurement in Arc OSS and trigger enforcement through the REST API, which runs manually rather than on a schedule.
Continuous Queries
Downsample Arc data into materialized views with continuous queries: define the aggregation, choose a target measurement, and trigger runs through the REST API.