Learn to configure automated data retention policies in TimescaleDB for efficient storage management. Set up compression, continuous aggregates, and automated data lifecycle policies to optimize time-series database performance and reduce storage costs.
Prerequisites
- Root or sudo access
- At least 4GB RAM
- PostgreSQL 12+ compatible system
What this solves
TimescaleDB data retention policies automatically manage your time-series data lifecycle by dropping old data, compressing chunks, and maintaining continuous aggregates. This prevents storage bloat, reduces query costs, and maintains database performance as your time-series data grows. You'll implement automated retention rules that keep recent data accessible while compressing or removing historical data based on your business requirements.
Step-by-step configuration
Update system packages
Start by updating your package manager to ensure you have the latest PostgreSQL and system packages available.
sudo apt update && sudo apt upgrade -y
sudo apt install -y wget ca-certificates
Install PostgreSQL 16
TimescaleDB requires PostgreSQL 12 or higher. Install PostgreSQL 16 for the best performance and feature compatibility.
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt update
sudo apt install -y postgresql-16 postgresql-client-16 postgresql-contrib-16
Start and enable PostgreSQL
Enable PostgreSQL to start on boot and start the service immediately.
sudo systemctl enable --now postgresql
sudo systemctl status postgresql
Install TimescaleDB
Add the TimescaleDB repository and install the extension for PostgreSQL 16.
echo "deb https://packagecloud.io/timescale/timescaledb/ubuntu/ $(lsb_release -c -s) main" | sudo tee /etc/apt/sources.list.d/timescaledb.list
wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | sudo apt-key add -
sudo apt update
sudo apt install -y timescaledb-2-postgresql-16
Configure PostgreSQL for TimescaleDB
Run the timescaledb-tune utility to optimize PostgreSQL configuration for time-series workloads.
sudo timescaledb-tune --quiet --yes
Restart PostgreSQL
Restart PostgreSQL to apply the TimescaleDB configuration changes.
sudo systemctl restart postgresql
Create a database and enable TimescaleDB
Create a new database for time-series data and enable the TimescaleDB extension.
sudo -u postgres createdb metrics_db
sudo -u postgres psql -d metrics_db -c "CREATE EXTENSION IF NOT EXISTS timescaledb;"
Create a sample time-series table
Create a hypertable to demonstrate retention policies with sensor data.
sudo -u postgres psql -d metrics_db << 'EOF'
CREATE TABLE sensor_data (
time TIMESTAMPTZ NOT NULL,
sensor_id INTEGER NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
location TEXT
);
SELECT create_hypertable('sensor_data', 'time');
EOF
Insert sample data
Add sample time-series data spanning several months to test retention policies.
sudo -u postgres psql -d metrics_db << 'EOF'
INSERT INTO sensor_data (time, sensor_id, temperature, humidity, location)
SELECT
generate_series(NOW() - INTERVAL '6 months', NOW(), INTERVAL '1 hour') AS time,
(random() * 10 + 1)::INTEGER AS sensor_id,
(random() * 30 + 20) AS temperature,
(random() * 40 + 40) AS humidity,
'Building-' || (random() * 5 + 1)::INTEGER AS location;
EOF
Configure data retention policies
Create a basic retention policy
Set up a retention policy to automatically drop data older than 3 months.
sudo -u postgres psql -d metrics_db -c "
SELECT add_retention_policy('sensor_data', INTERVAL '3 months');
"
Enable compression
Enable compression for chunks older than 7 days to reduce storage usage while maintaining data accessibility.
sudo -u postgres psql -d metrics_db << 'EOF'
ALTER TABLE sensor_data SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'sensor_id',
timescaledb.compress_orderby = 'time DESC'
);
SELECT add_compression_policy('sensor_data', INTERVAL '7 days');
EOF
Create continuous aggregates
Set up continuous aggregates for efficient querying of historical data with automatic refresh policies.
sudo -u postgres psql -d metrics_db << 'EOF'
CREATE MATERIALIZED VIEW sensor_data_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
sensor_id,
location,
AVG(temperature) AS avg_temperature,
AVG(humidity) AS avg_humidity,
MIN(temperature) AS min_temperature,
MAX(temperature) AS max_temperature
FROM sensor_data
GROUP BY bucket, sensor_id, location;
CREATE MATERIALIZED VIEW sensor_data_daily
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 day', time) AS bucket,
sensor_id,
location,
AVG(temperature) AS avg_temperature,
AVG(humidity) AS avg_humidity,
MIN(temperature) AS min_temperature,
MAX(temperature) AS max_temperature
FROM sensor_data
GROUP BY bucket, sensor_id, location;
EOF
Configure continuous aggregate refresh policies
Add refresh policies to keep continuous aggregates up to date automatically.
sudo -u postgres psql -d metrics_db << 'EOF'
SELECT add_continuous_aggregate_policy('sensor_data_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
SELECT add_continuous_aggregate_policy('sensor_data_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day');
EOF
Set up retention for continuous aggregates
Configure retention policies for the continuous aggregates to manage their lifecycle independently.
sudo -u postgres psql -d metrics_db << 'EOF'
SELECT add_retention_policy('sensor_data_hourly', INTERVAL '1 year');
SELECT add_retention_policy('sensor_data_daily', INTERVAL '3 years');
EOF
Monitor and optimize retention performance
Check compression status
Monitor which chunks are compressed and their compression ratios to optimize storage.
sudo -u postgres psql -d metrics_db -c "
SELECT
chunk_schema,
chunk_name,
compression_status,
before_compression_total_bytes,
after_compression_total_bytes,
ROUND(100 - (after_compression_total_bytes::float / before_compression_total_bytes::float * 100), 2) AS compression_ratio_percent
FROM chunk_compression_stats('sensor_data')
ORDER BY chunk_name;
"
View retention policy status
Check all active retention and compression policies to ensure they're running as expected.
sudo -u postgres psql -d metrics_db -c "
SELECT
application_name,
schedule_interval,
max_runtime,
max_retries,
retry_period
FROM timescaledb_information.jobs
WHERE proc_name IN ('policy_retention', 'policy_compression', 'policy_refresh_continuous_aggregate');
"
Monitor job execution history
Review the execution history of retention and compression jobs to identify any failures or performance issues.
sudo -u postgres psql -d metrics_db -c "
SELECT
job_id,
start_time,
finish_time,
success,
error_message
FROM timescaledb_information.job_stats
ORDER BY start_time DESC
LIMIT 20;
"
Configure job scheduling
Adjust the schedule for retention and compression jobs to run during low-traffic periods.
sudo -u postgres psql -d metrics_db << 'EOF'
-- Run retention policy daily at 2 AM
SELECT alter_job(
(SELECT job_id FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention' AND hypertable_name = 'sensor_data'),
schedule_interval => INTERVAL '1 day',
max_runtime => INTERVAL '1 hour'
);
-- Run compression policy every 6 hours
SELECT alter_job(
(SELECT job_id FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression' AND hypertable_name = 'sensor_data'),
schedule_interval => INTERVAL '6 hours',
max_runtime => INTERVAL '2 hours'
);
EOF
Create monitoring views
Set up monitoring views to track storage usage and policy effectiveness over time.
sudo -u postgres psql -d metrics_db << 'EOF'
CREATE OR REPLACE VIEW storage_stats AS
SELECT
hypertable_name,
total_size_bytes,
pg_size_pretty(total_size_bytes) AS total_size,
compressed_heap_size_bytes,
pg_size_pretty(compressed_heap_size_bytes) AS compressed_size,
CASE
WHEN total_size_bytes > 0 THEN
ROUND(100 - (compressed_heap_size_bytes::float / total_size_bytes::float * 100), 2)
ELSE 0
END AS compression_ratio_percent
FROM hypertable_detailed_size('sensor_data');
CREATE OR REPLACE VIEW chunk_status AS
SELECT
chunk_name,
range_start,
range_end,
is_compressed,
compressed_heap_size,
pg_size_pretty(compressed_heap_size) AS compressed_size
FROM timescaledb_information.chunks
WHERE hypertable_name = 'sensor_data'
ORDER BY range_start DESC;
EOF
Advanced retention configurations
Create conditional retention policies
Set up more complex retention rules based on data characteristics using custom functions.
sudo -u postgres psql -d metrics_db << 'EOF'
-- Create function for conditional retention based on sensor importance
CREATE OR REPLACE FUNCTION conditional_retention_policy()
RETURNS VOID AS $$
DECLAREBEGIN
-- Keep critical sensor data (sensor_id 1-3) for 1 year
-- Keep normal sensor data for 3 months
DELETE FROM sensor_data
WHERE time < NOW() - INTERVAL '1 year'
AND sensor_id IN (1, 2, 3);
DELETE FROM sensor_data
WHERE time < NOW() - INTERVAL '3 months'
AND sensor_id NOT IN (1, 2, 3);
END;
$$ LANGUAGE plpgsql;
-- Schedule the custom retention policy
SELECT add_job('conditional_retention_policy', '1 day');
EOF
Configure alerting for retention policies
Create a function to monitor retention policy execution and alert on failures.
sudo -u postgres psql -d metrics_db << 'EOF'
CREATE OR REPLACE FUNCTION check_retention_health()
RETURNS TABLE(job_name TEXT, last_success TIMESTAMPTZ, status TEXT) AS $$
BEGIN
RETURN QUERY
SELECT
j.application_name::TEXT,
js.last_successful_finish,
CASE
WHEN js.last_successful_finish < NOW() - INTERVAL '2 days' THEN 'STALE'
WHEN js.consecutive_failures > 3 THEN 'FAILING'
ELSE 'OK'
END::TEXT
FROM timescaledb_information.jobs j
JOIN timescaledb_information.job_stats js ON j.job_id = js.job_id
WHERE j.proc_name IN ('policy_retention', 'policy_compression');
END;
$$ LANGUAGE plpgsql;
EOF
Verify your setup
Check that your retention policies are working correctly and data is being managed as expected.
sudo -u postgres psql -d metrics_db -c "SELECT * FROM storage_stats;"
sudo -u postgres psql -d metrics_db -c "SELECT * FROM chunk_status LIMIT 10;"
sudo -u postgres psql -d metrics_db -c "SELECT * FROM check_retention_health();"
sudo -u postgres psql -d metrics_db -c "SELECT COUNT(*) AS total_records FROM sensor_data;"
sudo -u postgres psql -d metrics_db -c "SELECT COUNT(*) AS compressed_chunks FROM timescaledb_information.chunks WHERE is_compressed = true AND hypertable_name = 'sensor_data';"
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Retention policy not executing | Background worker not running | SELECT timescaledb_pre_restore(); then restart PostgreSQL |
| Compression failing | Active transactions on chunks | Wait for transactions to complete or restart PostgreSQL |
| High storage usage despite retention | VACUUM not running after deletion | VACUUM ANALYZE sensor_data; |
| Continuous aggregate not refreshing | Policy disabled or failing | Check job status: SELECT * FROM timescaledb_information.job_stats; |
| Query performance degraded | Missing indexes on compressed chunks | Add indexes before enabling compression |
| Extension not loading | Missing shared_preload_libraries | Add 'timescaledb' to postgresql.conf and restart |
Next steps
- Configure TimescaleDB backup and recovery with pgBackRest for automated PostgreSQL protection
- Configure Grafana dashboards for TimescaleDB analytics with real-time metrics and alerting
- Set up TimescaleDB high availability with streaming replication and automatic failover
- Configure TimescaleDB multi-node clustering for distributed time-series workloads
- Implement TimescaleDB advanced compression strategies for optimal storage efficiency
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
TOTAL_STEPS=8
DB_NAME="${1:-timeseries_db}"
DB_USER="${2:-tsdb_user}"
DB_PASSWORD=""
POSTGRES_VERSION="16"
# Error handling
cleanup() {
echo -e "${RED}[ERROR] Installation failed. Cleaning up...${NC}"
if systemctl is-active --quiet postgresql 2>/dev/null || systemctl is-active --quiet postgresql-${POSTGRES_VERSION} 2>/dev/null; then
echo "PostgreSQL service is running, leaving it as is for manual inspection"
fi
}
trap cleanup ERR
usage() {
echo "Usage: $0 [database_name] [database_user]"
echo " database_name: Name of the TimescaleDB database (default: timeseries_db)"
echo " database_user: Database user for TimescaleDB (default: tsdb_user)"
echo "Example: $0 mytsdb myuser"
exit 1
}
# Check if help is requested
if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then
usage
fi
# Check prerequisites
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}[ERROR] This script must be run as root or with sudo${NC}"
exit 1
fi
# Auto-detect distribution
if [[ ! -f /etc/os-release ]]; then
echo -e "${RED}[ERROR] Cannot detect Linux distribution${NC}"
exit 1
fi
source /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_UPDATE="apt update"
PKG_INSTALL="apt install -y"
PKG_UPGRADE="apt upgrade -y"
PG_SERVICE="postgresql"
PG_CONFIG_DIR="/etc/postgresql/${POSTGRES_VERSION}/main"
PG_DATA_DIR="/var/lib/postgresql/${POSTGRES_VERSION}/main"
;;
almalinux|rocky|centos|rhel|ol)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
PKG_UPGRADE="dnf update -y"
PG_SERVICE="postgresql-${POSTGRES_VERSION}"
PG_CONFIG_DIR="/var/lib/pgsql/${POSTGRES_VERSION}/data"
PG_DATA_DIR="/var/lib/pgsql/${POSTGRES_VERSION}/data"
;;
amzn)
PKG_MGR="yum"
PKG_UPDATE="yum update -y"
PKG_INSTALL="yum install -y"
PKG_UPGRADE="yum update -y"
PG_SERVICE="postgresql-${POSTGRES_VERSION}"
PG_CONFIG_DIR="/var/lib/pgsql/${POSTGRES_VERSION}/data"
PG_DATA_DIR="/var/lib/pgsql/${POSTGRES_VERSION}/data"
;;
fedora)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
PKG_UPGRADE="dnf update -y"
PG_SERVICE="postgresql"
PG_CONFIG_DIR="/var/lib/pgsql/data"
PG_DATA_DIR="/var/lib/pgsql/data"
;;
*)
echo -e "${RED}[ERROR] Unsupported distribution: $ID${NC}"
exit 1
;;
esac
echo -e "${BLUE}[INFO] Detected distribution: $PRETTY_NAME${NC}"
echo -e "${BLUE}[INFO] Using package manager: $PKG_MGR${NC}"
# Generate random password
DB_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25)
echo -e "${GREEN}[1/$TOTAL_STEPS] Updating system packages...${NC}"
$PKG_UPDATE
$PKG_INSTALL wget ca-certificates curl gnupg lsb-release openssl
echo -e "${GREEN}[2/$TOTAL_STEPS] Adding PostgreSQL repository...${NC}"
case "$ID" in
ubuntu|debian)
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
$PKG_UPDATE
;;
almalinux|rocky|centos|rhel|ol|amzn)
if [[ "$ID" == "amzn" ]]; then
$PKG_INSTALL https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
else
VERSION_ID_MAJOR=$(echo $VERSION_ID | cut -d. -f1)
$PKG_INSTALL https://download.postgresql.org/pub/repos/yum/reporpms/EL-${VERSION_ID_MAJOR}-x86_64/pgdg-redhat-repo-latest.noarch.rpm
fi
;;
esac
echo -e "${GREEN}[3/$TOTAL_STEPS] Installing PostgreSQL ${POSTGRES_VERSION}...${NC}"
case "$ID" in
ubuntu|debian)
$PKG_INSTALL postgresql-${POSTGRES_VERSION} postgresql-client-${POSTGRES_VERSION} postgresql-contrib-${POSTGRES_VERSION}
;;
almalinux|rocky|centos|rhel|ol|amzn)
$PKG_INSTALL postgresql${POSTGRES_VERSION}-server postgresql${POSTGRES_VERSION}-contrib
/usr/pgsql-${POSTGRES_VERSION}/bin/postgresql-${POSTGRES_VERSION}-setup initdb
;;
fedora)
$PKG_INSTALL postgresql-server postgresql-contrib
postgresql-setup --initdb
;;
esac
echo -e "${GREEN}[4/$TOTAL_STEPS] Starting and enabling PostgreSQL...${NC}"
systemctl enable $PG_SERVICE
systemctl start $PG_SERVICE
systemctl status $PG_SERVICE --no-pager -l
echo -e "${GREEN}[5/$TOTAL_STEPS] Adding TimescaleDB repository...${NC}"
case "$ID" in
ubuntu|debian)
curl -fsSL https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /usr/share/keyrings/timescaledb-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/timescaledb-keyring.gpg] https://packagecloud.io/timescale/timescaledb/ubuntu/ $(lsb_release -c -s) main" > /etc/apt/sources.list.d/timescaledb.list
$PKG_UPDATE
;;
almalinux|rocky|centos|rhel|ol|amzn)
cat > /etc/yum.repos.d/timescale_timescaledb.repo <<EOF
[timescale_timescaledb]
name=timescale_timescaledb
baseurl=https://packagecloud.io/timescale/timescaledb/el/\$releasever/\$basearch
repo_gpgcheck=1
gpgcheck=0
enabled=1
gpgkey=https://packagecloud.io/timescale/timescaledb/gpgkey
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
metadata_expire=300
EOF
;;
esac
echo -e "${GREEN}[6/$TOTAL_STEPS] Installing TimescaleDB...${NC}"
case "$ID" in
ubuntu|debian)
$PKG_INSTALL timescaledb-2-postgresql-${POSTGRES_VERSION}
;;
almalinux|rocky|centos|rhel|ol|amzn)
$PKG_INSTALL timescaledb-2-postgresql${POSTGRES_VERSION}
;;
esac
echo -e "${GREEN}[7/$TOTAL_STEPS] Configuring TimescaleDB...${NC}"
# Run timescaledb-tune for optimization
timescaledb-tune --quiet --yes || echo -e "${YELLOW}[WARNING] TimescaleDB tune failed, continuing...${NC}"
# Add TimescaleDB to postgresql.conf
if [[ "$ID" == "ubuntu" ]] || [[ "$ID" == "debian" ]]; then
PG_CONF="/etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf"
else
PG_CONF="${PG_CONFIG_DIR}/postgresql.conf"
fi
# Backup original config
cp "$PG_CONF" "${PG_CONF}.backup.$(date +%Y%m%d_%H%M%S)"
# Add TimescaleDB to shared_preload_libraries if not already present
if ! grep -q "timescaledb" "$PG_CONF"; then
sed -i "s/#shared_preload_libraries = ''/shared_preload_libraries = 'timescaledb'/" "$PG_CONF"
sed -i "s/shared_preload_libraries = ''/shared_preload_libraries = 'timescaledb'/" "$PG_CONF"
sed -i "s/shared_preload_libraries = 'timescaledb'/shared_preload_libraries = 'timescaledb'/" "$PG_CONF"
echo "shared_preload_libraries = 'timescaledb'" >> "$PG_CONF"
fi
# Restart PostgreSQL to load TimescaleDB
systemctl restart $PG_SERVICE
echo -e "${GREEN}[8/$TOTAL_STEPS] Setting up database and user...${NC}"
# Create database and user
sudo -u postgres psql <<EOF
CREATE DATABASE $DB_NAME;
CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
ALTER USER $DB_USER CREATEDB;
\q
EOF
# Enable TimescaleDB extension
sudo -u postgres psql -d $DB_NAME -c "CREATE EXTENSION IF NOT EXISTS timescaledb;"
# Create sample configuration for data retention
sudo -u postgres psql -d $DB_NAME <<EOF
-- Example: Create a sample hypertable and retention policy
CREATE TABLE IF NOT EXISTS metrics (
time TIMESTAMPTZ NOT NULL,
device_id INTEGER,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION
);
-- Convert to hypertable
SELECT create_hypertable('metrics', 'time', if_not_exists => TRUE);
-- Add retention policy (keep data for 30 days, compress after 7 days)
SELECT add_retention_policy('metrics', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_compression_policy('metrics', INTERVAL '7 days', if_not_exists => TRUE);
-- Create continuous aggregate for hourly averages
CREATE MATERIALIZED VIEW IF NOT EXISTS metrics_hourly
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', time) AS bucket,
device_id,
AVG(temperature) as avg_temperature,
AVG(humidity) as avg_humidity
FROM metrics
GROUP BY bucket, device_id;
-- Add retention policy for continuous aggregate (keep for 1 year)
SELECT add_retention_policy('metrics_hourly', INTERVAL '365 days', if_not_exists => TRUE);
\q
EOF
echo -e "${GREEN}[SUCCESS] TimescaleDB installation completed successfully!${NC}"
echo ""
echo -e "${BLUE}Database Information:${NC}"
echo " Database Name: $DB_NAME"
echo " Database User: $DB_USER"
echo " Database Password: $DB_PASSWORD"
echo " PostgreSQL Service: $PG_SERVICE"
echo ""
echo -e "${BLUE}Connection Examples:${NC}"
echo " psql -h localhost -U $DB_USER -d $DB_NAME"
echo " Connection string: postgresql://$DB_USER:$DB_PASSWORD@localhost:5432/$DB_NAME"
echo ""
echo -e "${YELLOW}[INFO] Sample hypertable 'metrics' created with retention policies${NC}"
echo -e "${YELLOW}[INFO] Data retention: 30 days, Compression: 7 days${NC}"
echo -e "${YELLOW}[INFO] Save the database credentials in a secure location!${NC}"
# Verify installation
echo -e "${GREEN}Verifying installation...${NC}"
sudo -u postgres psql -d $DB_NAME -c "SELECT default_version, installed_version FROM pg_available_extensions WHERE name = 'timescaledb';" || echo -e "${RED}[ERROR] TimescaleDB verification failed${NC}"
Review the script before running. Execute with: bash install.sh