read-only-postgres
Execute read-only SQL queries against PostgreSQL databases. Use when: (1) querying PostgreSQL data, (2) exploring schemas/tables, (3) running SELECT queries for analysis, (4) checking database contents. Supports multiple database connections with descriptions for auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install jawwadfirdousi-agent-skills-read-only-postgres
Repository
Skill path: skills/read-only-postgres
Execute read-only SQL queries against PostgreSQL databases. Use when: (1) querying PostgreSQL data, (2) exploring schemas/tables, (3) running SELECT queries for analysis, (4) checking database contents. Supports multiple database connections with descriptions for auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety.
Open repositoryBest for
Primary workflow: Analyze Data & AI.
Technical facets: Full Stack, Backend, Data / AI.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: jawwadfirdousi.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install read-only-postgres into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/jawwadfirdousi/agent-skills before adding read-only-postgres to shared team environments
- Use read-only-postgres for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: read-only-postgres
description: "Execute read-only SQL queries against PostgreSQL databases. Use when: (1) querying PostgreSQL data, (2) exploring schemas/tables, (3) running SELECT queries for analysis, (4) checking database contents. Supports multiple database connections with descriptions for auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety."
---
# PostgreSQL Read-Only Query Skill
Execute safe, read-only queries against configured PostgreSQL databases.
## Requirements
- Python 3.8+
- psycopg2-binary: `pip install -r requirements.txt`
## Setup
Create `connections.json` in the skill directory or `~/.config/claude/read-only-postgres-connections.json`.
**Security**: Set file permissions to `600` since it contains credentials:
```bash
chmod 600 connections.json
```
```json
{
"databases": [
{
"name": "app-db-dev",
"description": "Primary app database (public schema: users, organizations, orders, order_items, events)",
"host": "localhost",
"port": 5432,
"database": "app_dev",
"user": "app_user",
"password": "app_password",
"sslmode": "disable"
},
{
"name": "app-db-staging",
"description": "Staging database (same schema as primary app)",
"host": "localhost",
"port": 5432,
"database": "app_staging",
"user": "app_user",
"password": "app_password",
"sslmode": "disable"
}
]
}
```
### Config Fields
| Field | Required | Description |
|-------|----------|-------------|
| name | Yes | Identifier for the database (case-insensitive) |
| description | Yes | What data this database contains (used for auto-selection) |
| host | Yes | Database hostname |
| port | No | Port number (default: 5432) |
| database | Yes | Database name |
| user | Yes | Username |
| password | Yes | Password |
| sslmode | No | SSL mode: disable, allow, prefer (default), require, verify-ca, verify-full |
## Usage
### List configured databases
```bash
python3 scripts/query.py --list
```
### Query a database
```bash
python3 scripts/query.py --db app-db-dev --query "SELECT id, email, created_at FROM users LIMIT 10"
```
### List tables
```bash
python3 scripts/query.py --db app-db-dev --tables
```
### Show schema
```bash
python3 scripts/query.py --db app-db-dev --schema
```
### Limit results
```bash
python3 scripts/query.py --db app-db-dev --query "SELECT id, status, total_amount FROM orders" --limit 100
```
## Database Selection
Match user intent to database `description`:
| User asks about | Look for description containing |
|-----------------|--------------------------------|
| users, accounts | users, accounts |
| organizations, teams | organizations, teams |
| orders, payments | orders, payments |
| events, audit logs | events, audit, logs |
| analytics or reporting | analytics, reporting |
| background jobs or queues | jobs, queue, outbox |
If unclear, run `--list` and ask user which database.
## Safety Features
- **Read-only session**: Connection uses PostgreSQL `readonly=True` mode (primary protection)
- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH queries allowed (comments/literals stripped; DDL/DML keywords, data-modifying CTEs, SELECT INTO, and sequence mutation functions blocked)
- **Single statement**: Multiple statements per query rejected
- **SSL support**: Configurable SSL mode for encrypted connections
- **Query timeout**: 30-second statement timeout enforced
- **Memory protection**: Max 10,000 rows per query to prevent OOM
- **Column width cap**: 100 char max per column for readable output
- **Credential sanitization**: Error messages don't leak passwords
## Troubleshooting
| Error | Solution |
|-------|----------|
| Config not found | Create `connections.json` in skill directory |
| Authentication failed | Check username/password in config |
| Connection timeout | Verify host/port, check firewall/VPN |
| SSL error | Try `"sslmode": "disable"` for local databases |
| Permission warning | Run `chmod 600 connections.json` |
## Exit Codes
- **0**: Success
- **1**: Error (config missing, auth failed, invalid query, database error)
## Workflow
1. Run `--list` to show available databases
2. Match user intent to database description
3. Run `--tables` or `--schema` to explore structure
4. Execute query with appropriate LIMIT
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### scripts/query.py
```python
#!/usr/bin/env python3
"""
Read-only PostgreSQL query executor.
Connects to configured databases and executes SELECT queries only.
"""
import json
import os
import re
import stat
import sys
import argparse
from pathlib import Path
from typing import Optional
try:
import psycopg2
except ImportError:
print("Error: psycopg2 not installed. Run: pip install psycopg2-binary")
sys.exit(1)
# Constants
SCRIPT_DIR = Path(__file__).parent.parent
CONFIG_LOCATIONS = [
SCRIPT_DIR / "connections.json",
Path.home() / ".config" / "claude" / "read-only-postgres-connections.json",
]
MAX_ROWS = 10000
MAX_COLUMN_WIDTH = 100
QUERY_TIMEOUT_MS = 30000
CONNECTION_TIMEOUT_SEC = 10
NULL_DISPLAY = "<NULL>"
ALLOWED_PREFIXES = ("SELECT", "SHOW", "EXPLAIN", "WITH")
DISALLOWED_KEYWORDS = (
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"UPSERT",
"REPLACE",
"ALTER",
"CREATE",
"DROP",
"TRUNCATE",
"GRANT",
"REVOKE",
"CALL",
"DO",
"EXECUTE",
"COPY",
"REFRESH",
"CLUSTER",
"VACUUM",
"ANALYZE",
"REINDEX",
"CHECKPOINT",
"LOCK",
"LISTEN",
"NOTIFY",
"UNLISTEN",
"SECURITY",
"RESET",
"SET",
"BEGIN",
"COMMIT",
"ROLLBACK",
"SAVEPOINT",
"RELEASE",
"TRANSACTION",
"INTO",
)
DISALLOWED_FUNCTIONS = (
"nextval",
"setval",
)
DISALLOWED_KEYWORDS_RE = re.compile(
r"\b(" + "|".join(DISALLOWED_KEYWORDS) + r")\b", re.IGNORECASE
)
DISALLOWED_FUNCTIONS_RE = re.compile(
r"\b(" + "|".join(DISALLOWED_FUNCTIONS) + r")\s*\(", re.IGNORECASE
)
def strip_literals_and_comments(query: str) -> str:
"""Strip SQL literals and comments to avoid false keyword matches."""
result = []
i = 0
in_single = False
in_double = False
in_line_comment = False
in_block_comment = False
dollar_tag = None
while i < len(query):
ch = query[i]
if in_line_comment:
if ch == "\n":
in_line_comment = False
result.append(" ")
i += 1
continue
if in_block_comment:
if ch == "*" and i + 1 < len(query) and query[i + 1] == "/":
in_block_comment = False
i += 2
result.append(" ")
else:
i += 1
continue
if dollar_tag:
if query.startswith(dollar_tag, i):
dollar_tag = None
i += len(dollar_tag)
result.append(" ")
else:
i += 1
continue
if in_single:
if ch == "'":
if i + 1 < len(query) and query[i + 1] == "'":
i += 2
else:
in_single = False
i += 1
result.append(" ")
else:
i += 1
continue
if in_double:
if ch == '"':
if i + 1 < len(query) and query[i + 1] == '"':
i += 2
else:
in_double = False
i += 1
result.append(" ")
else:
i += 1
continue
if ch == "-" and i + 1 < len(query) and query[i + 1] == "-":
in_line_comment = True
i += 2
continue
if ch == "/" and i + 1 < len(query) and query[i + 1] == "*":
in_block_comment = True
i += 2
continue
if ch == "'":
in_single = True
i += 1
continue
if ch == '"':
in_double = True
i += 1
continue
if ch == "$":
end = query.find("$", i + 1)
if end != -1:
tag = query[i : end + 1]
if re.fullmatch(r"\$[A-Za-z0-9_]*\$", tag):
dollar_tag = tag
i += len(tag)
continue
result.append(ch)
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
def is_read_only(query: str) -> bool:
"""Conservative client-side check for read-only queries."""
stripped = strip_literals_and_comments(query)
stripped_upper = stripped.upper().strip()
if not any(stripped_upper.startswith(cmd) for cmd in ALLOWED_PREFIXES):
return False
if DISALLOWED_KEYWORDS_RE.search(stripped):
return False
if DISALLOWED_FUNCTIONS_RE.search(stripped):
return False
return True
def validate_single_statement(query: str) -> bool:
"""Check query contains only one statement."""
# Remove trailing semicolon and whitespace, then check for remaining semicolons
clean = query.rstrip().rstrip(';')
return ';' not in clean
def validate_config_permissions(path: Path) -> None:
"""Warn if config file has insecure permissions (Unix only)."""
if os.name != 'nt': # Skip on Windows
mode = path.stat().st_mode
if bool(mode & stat.S_IRWXG) or bool(mode & stat.S_IRWXO):
print(f"WARNING: {path} has insecure permissions!")
print(f"Config contains credentials. Run: chmod 600 {path}")
def validate_db_config(db: dict) -> None:
"""Validate required fields exist in database config."""
required = ['name', 'host', 'database', 'user', 'password']
missing = [f for f in required if f not in db]
if missing:
print(f"Error: Database config missing fields: {', '.join(missing)}")
sys.exit(1)
def find_config() -> Optional[Path]:
"""Find config file in supported locations."""
for path in CONFIG_LOCATIONS:
if path.exists():
return path
return None
def load_config(config_path: Optional[Path] = None) -> dict:
"""Load database connections from JSON config."""
path = config_path or find_config()
if not path:
print("Config not found. Searched:")
for loc in CONFIG_LOCATIONS:
print(f" - {loc}")
print("\nCreate connections.json with format:")
print(json.dumps({
"databases": [{
"name": "mydb",
"description": "Description of database contents",
"host": "localhost",
"port": 5432,
"database": "mydb",
"user": "user",
"password": "password",
"sslmode": "prefer"
}]
}, indent=2))
sys.exit(1)
validate_config_permissions(path)
with open(path) as f:
return json.load(f)
def list_databases(config: dict) -> None:
"""List all configured databases."""
print("Configured databases:\n")
for db in config.get("databases", []):
validate_db_config(db)
print(f" [{db['name']}]")
print(f" Host: {db['host']}:{db.get('port', 5432)}")
print(f" Database: {db['database']}")
print(f" Description: {db.get('description', 'No description')}")
print()
def execute_query(db_config: dict, query: str, limit: Optional[int] = None) -> None:
"""Execute a read-only query against the specified database."""
if not is_read_only(query):
print(
"Error: Only read-only queries (SELECT, SHOW, EXPLAIN, WITH) are allowed. "
"Data-modifying keywords are blocked."
)
sys.exit(1)
if not validate_single_statement(query):
print("Error: Multiple statements not allowed. Execute queries separately.")
sys.exit(1)
# Apply limit using regex to avoid false positives from string content
if limit and not re.search(r'\bLIMIT\s+\d+', query, re.IGNORECASE):
query = f"{query.rstrip(';')} LIMIT {limit}"
conn = None
try:
conn = psycopg2.connect(
host=db_config['host'],
port=db_config.get('port', 5432),
database=db_config['database'],
user=db_config['user'],
password=db_config['password'],
sslmode=db_config.get('sslmode', 'prefer'),
connect_timeout=CONNECTION_TIMEOUT_SEC,
options=f'-c statement_timeout={QUERY_TIMEOUT_MS}'
)
# Primary safety: readonly session prevents any write operations
conn.set_session(readonly=True, autocommit=True)
with conn.cursor() as cur:
cur.execute(query)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchmany(MAX_ROWS)
truncated = len(rows) == MAX_ROWS
# Calculate column widths with cap
widths = [min(len(col), MAX_COLUMN_WIDTH) for col in columns]
for row in rows:
for i, val in enumerate(row):
val_str = str(val) if val is not None else NULL_DISPLAY
widths[i] = min(max(widths[i], len(val_str)), MAX_COLUMN_WIDTH)
# Print header
header = " | ".join(col[:MAX_COLUMN_WIDTH].ljust(widths[i]) for i, col in enumerate(columns))
print(header)
print("-" * len(header))
# Print rows
for row in rows:
cells = []
for i, val in enumerate(row):
val_str = str(val) if val is not None else NULL_DISPLAY
if len(val_str) > MAX_COLUMN_WIDTH:
val_str = val_str[:MAX_COLUMN_WIDTH-3] + "..."
cells.append(val_str.ljust(widths[i]))
print(" | ".join(cells))
msg = f"\n({len(rows)} rows)"
if truncated:
msg += f" [truncated at {MAX_ROWS}]"
print(msg)
else:
print("Query executed (no result set returned)")
except psycopg2.Error as e:
error_msg = str(e)
# Sanitize to avoid leaking credentials
if 'password' in error_msg.lower() or 'authentication' in error_msg.lower():
error_msg = "Authentication failed. Check credentials in connections.json"
print(f"Database error: {error_msg}")
sys.exit(1)
finally:
if conn:
conn.close()
def find_database(config: dict, name: str) -> dict:
"""Find database config by name (case-insensitive)."""
for db in config.get("databases", []):
if db.get('name', '').lower() == name.lower():
validate_db_config(db)
return db
available = [db.get('name', 'unnamed') for db in config.get("databases", [])]
print(f"Database '{name}' not found.")
print(f"Available: {', '.join(available)}")
sys.exit(1)
def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Execute read-only PostgreSQL queries",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --list
%(prog)s --db mydb --tables
%(prog)s --db mydb --query "SELECT * FROM users" --limit 100
"""
)
parser.add_argument("--config", "-c", type=Path, help="Path to config JSON")
parser.add_argument("--db", "-d", help="Database name to query")
parser.add_argument("--query", "-q", help="SQL query to execute")
parser.add_argument("--limit", "-l", type=int, help="Limit rows returned")
parser.add_argument("--list", action="store_true", help="List configured databases")
parser.add_argument("--schema", "-s", action="store_true", help="Show database schema")
parser.add_argument("--tables", "-t", action="store_true", help="List tables")
args = parser.parse_args()
config = load_config(args.config)
if args.list:
list_databases(config)
return
if not args.db:
print("Error: --db required. Use --list to see available databases.")
sys.exit(1)
db_config = find_database(config, args.db)
if args.tables:
query = """
SELECT table_schema, table_name, table_type
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name
"""
execute_query(db_config, query, args.limit)
elif args.schema:
query = """
SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable
FROM information_schema.columns c
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.table_schema, c.table_name, c.ordinal_position
"""
execute_query(db_config, query, args.limit)
elif args.query:
execute_query(db_config, args.query, args.limit)
else:
print("Error: --query, --tables, or --schema required")
sys.exit(1)
if __name__ == "__main__":
main()
```