Maintaining consistent code quality across a development team can be challenging. SwiftLint helps enforce Swift style and conventions automatically, catching issues before they make it to code review. In this comprehensive guide, we'll set up SwiftLint, integrate it with Xcode builds, and automate it with GitHub Actions for continuous quality enforcement.

What is SwiftLint?

SwiftLint is a tool to enforce Swift style and conventions based on GitHub's Swift Style Guide. It runs as a command-line tool and can be integrated into your development workflow to:

  • Enforce consistent coding standards across your team
  • Catch common mistakes and code smells
  • Automatically format code to match style guidelines
  • Prevent bad code from being committed
  • Improve code readability and maintainability

Why Use SwiftLint?

SwiftLint catches over 200+ style issues automatically, from simple spacing problems to complex naming conventions. It saves hours in code review by handling style discussions programmatically.

Installation Methods

Method 1: Homebrew (Recommended for Local Development)

Terminal
# Install SwiftLint
brew install swiftlint

# Verify installation
swiftlint version

Method 2: CocoaPods

Podfile
target 'YourApp' do
  use_frameworks!
  
  # Other pods...
  
  # Add SwiftLint to your dev pods
  pod 'SwiftLint'
end
Terminal
# Install pods
pod install

Method 3: Swift Package Manager (SPM)

Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "YourApp",
    platforms: [.iOS(.v15)],
    dependencies: [
        .package(url: "https://github.com/realm/SwiftLint", from: "0.54.0")
    ],
    targets: [
        .target(
            name: "YourApp",
            plugins: [.plugin(name: "SwiftLintPlugin", package: "SwiftLint")]
        )
    ]
)

Configuring SwiftLint

Create a .swiftlint.yml file in your project root to customize SwiftLint rules:

.swiftlint.yml
# SwiftLint Configuration File

# Paths to include during linting
included:
  - Sources
  - Tests
  - YourApp

# Paths to ignore during linting
excluded:
  - Pods
  - Carthage
  - .build
  - DerivedData
  - Generated
  - Vendor

# Disable rules
disabled_rules:
  - trailing_whitespace
  - todo
  - line_length  # Will configure this below

# Opt-in rules (not enabled by default)
opt_in_rules:
  - empty_count
  - empty_string
  - explicit_init
  - first_where
  - closure_spacing
  - contains_over_first_not_nil
  - convenience_type
  - discouraged_optional_boolean
  - empty_xctest_method
  - explicit_type_interface
  - fatal_error_message
  - file_header
  - force_unwrapping
  - implicit_return
  - joined_default_parameter
  - literal_expression_end_indentation
  - multiline_arguments
  - multiline_function_chains
  - multiline_parameters
  - operator_usage_whitespace
  - overridden_super_call
  - pattern_matching_keywords
  - private_action
  - private_outlet
  - prohibited_super_call
  - redundant_nil_coalescing
  - single_test_class
  - sorted_first_last
  - sorted_imports
  - toggle_bool
  - unavailable_function
  - unneeded_parentheses_in_closure_argument
  - vertical_parameter_alignment_on_call
  - yoda_condition

# Rule configurations
line_length:
  warning: 120
  error: 200
  ignores_function_declarations: true
  ignores_comments: true
  ignores_urls: true

function_body_length:
  warning: 50
  error: 100

function_parameter_count:
  warning: 5
  error: 8

type_body_length:
  warning: 300
  error: 500

file_length:
  warning: 500
  error: 1000
  ignore_comment_only_lines: true

cyclomatic_complexity:
  warning: 10
  error: 20

nesting:
  type_level:
    warning: 3
  statement_level:
    warning: 5

identifier_name:
  min_length:
    warning: 3
  max_length:
    warning: 40
  excluded:
    - id
    - URL
    - db
    - i
    - x
    - y

type_name:
  min_length: 3
  max_length: 40

# Custom rules
custom_rules:
  # Ensure print statements aren't committed
  no_print:
    name: "Print statements"
    regex: "print\\("
    match_kinds: identifier
    message: "Avoid using print() in production code. Use proper logging."
    severity: warning
  
  # Require TODO comments to have a ticket number
  todo_with_ticket:
    name: "TODO with ticket"
    regex: "TODO(?!\\s*\\[\\w+-\\d+\\])"
    message: "TODOs must include a ticket number [PROJ-123]"
    severity: warning
  
  # Discourage force try
  no_force_try:
    name: "Force Try"
    regex: "try!"
    message: "Avoid using 'try!' - handle errors properly"
    severity: error

# Paths to include in reporter output
reporter: "xcode"

Configuration Best Practice

Start with a minimal configuration and gradually add rules. Enabling too many strict rules at once can overwhelm your team and slow down development.

Integrating SwiftLint with Xcode Build

The most effective way to use SwiftLint is to run it automatically during every build.

Step 1: Add Run Script Phase

  1. Open your Xcode project
  2. Select your target
  3. Go to Build Phases tab
  4. Click + → New Run Script Phase
  5. Drag the new phase to run before "Compile Sources"
  6. Name it "SwiftLint"

Step 2: Add SwiftLint Script

Run Script (Basic)
#!/bin/bash

# Check if SwiftLint is installed
if which swiftlint >/dev/null; then
    swiftlint
else
    echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
fi

Advanced Script with Better Error Handling

Run Script (Advanced)
#!/bin/bash

# Define colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

echo "${GREEN}šŸ” Running SwiftLint...${NC}"

# Check for Homebrew installation
if which swiftlint >/dev/null; then
    SWIFTLINT_PATH=$(which swiftlint)
    echo "${GREEN}āœ“ Found SwiftLint at: ${SWIFTLINT_PATH}${NC}"
# Check for CocoaPods installation
elif [ -f "${PODS_ROOT}/SwiftLint/swiftlint" ]; then
    SWIFTLINT_PATH="${PODS_ROOT}/SwiftLint/swiftlint"
    echo "${GREEN}āœ“ Found SwiftLint in Pods${NC}"
else
    echo "${RED}āœ— SwiftLint not found${NC}"
    echo "${YELLOW}Install via: brew install swiftlint${NC}"
    exit 0  # Don't fail the build, just warn
fi

# Run SwiftLint
$SWIFTLINT_PATH lint --config .swiftlint.yml

# Capture exit code
LINT_EXIT_CODE=$?

if [ $LINT_EXIT_CODE -eq 0 ]; then
    echo "${GREEN}āœ“ SwiftLint passed with no issues!${NC}"
else
    echo "${RED}āœ— SwiftLint found issues${NC}"
fi

# Exit with the SwiftLint exit code
exit $LINT_EXIT_CODE

Auto-fix Script (Optional)

Create a separate script to auto-fix issues:

scripts/swiftlint-autofix.sh
#!/bin/bash

# SwiftLint auto-fix script
# Run this manually when you want to fix issues automatically

echo "šŸ”§ Running SwiftLint auto-fix..."

if which swiftlint >/dev/null; then
    swiftlint --fix --format
    echo "āœ“ Auto-fix complete!"
    echo "āš ļø  Review changes before committing"
else
    echo "āœ— SwiftLint not installed"
    exit 1
fi
Terminal
# Make the script executable
chmod +x scripts/swiftlint-autofix.sh

# Run it
./scripts/swiftlint-autofix.sh

Git Hooks for Pre-Commit Linting

Prevent bad code from being committed by adding a pre-commit hook:

.git/hooks/pre-commit
#!/bin/bash

# SwiftLint pre-commit hook
# Runs SwiftLint on staged Swift files before commit

echo "šŸ” Running SwiftLint on staged files..."

# Get list of staged Swift files
STAGED_SWIFT_FILES=$(git diff --cached --name-only --diff-filter=d | grep ".swift$")

if [ -z "$STAGED_SWIFT_FILES" ]; then
    echo "āœ“ No Swift files to lint"
    exit 0
fi

# Check if SwiftLint is installed
if ! which swiftlint >/dev/null; then
    echo "āš ļø  SwiftLint not installed. Skipping..."
    exit 0
fi

# Run SwiftLint on staged files
LINT_ERRORS=0
for FILE in $STAGED_SWIFT_FILES; do
    swiftlint lint --path "$FILE" --quiet
    if [ $? -ne 0 ]; then
        LINT_ERRORS=1
    fi
done

if [ $LINT_ERRORS -ne 0 ]; then
    echo ""
    echo "āŒ SwiftLint found issues in your staged files"
    echo "Fix the issues or run 'swiftlint --fix' to auto-fix"
    echo "To commit anyway, use: git commit --no-verify"
    exit 1
fi

echo "āœ“ SwiftLint passed!"
exit 0

Installing the Pre-Commit Hook

Terminal
# Create hooks directory if it doesn't exist
mkdir -p .git/hooks

# Create the pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
# [Paste the script from above]
EOF

# Make it executable
chmod +x .git/hooks/pre-commit

echo "āœ“ Pre-commit hook installed!"

GitHub Actions Integration

Automate SwiftLint in your CI/CD pipeline to ensure all pull requests meet code standards.

Method 1: Basic GitHub Action

.github/workflows/swiftlint.yml
name: SwiftLint

on:
  pull_request:
    paths:
      - '**.swift'
  push:
    branches:
      - main
      - develop

jobs:
  SwiftLint:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: SwiftLint
        uses: norio-nomura/action-swiftlint@3.2.1
        with:
          args: --strict

Method 2: Advanced GitHub Action with Annotations

.github/workflows/swiftlint-advanced.yml
name: SwiftLint Advanced

on:
  pull_request:
    paths:
      - '**.swift'
      - '.swiftlint.yml'
  push:
    branches:
      - main
      - develop

jobs:
  swiftlint:
    name: SwiftLint Analysis
    runs-on: macos-latest
    
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Cache SwiftLint
        uses: actions/cache@v3
        with:
          path: /usr/local/bin/swiftlint
          key: ${{ runner.os }}-swiftlint-${{ hashFiles('.swiftlint.yml') }}
          restore-keys: |
            ${{ runner.os }}-swiftlint-
      
      - name: Install SwiftLint
        run: |
          if ! which swiftlint >/dev/null; then
            brew install swiftlint
          fi
          swiftlint version
      
      - name: Run SwiftLint
        run: |
          swiftlint lint --reporter github-actions-logging
      
      - name: Run SwiftLint (Strict on PR)
        if: github.event_name == 'pull_request'
        run: |
          swiftlint lint --strict --reporter github-actions-logging
      
      - name: Generate SwiftLint Report
        if: always()
        run: |
          swiftlint lint --reporter html > swiftlint-report.html
      
      - name: Upload SwiftLint Report
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: swiftlint-report
          path: swiftlint-report.html
      
      - name: Comment PR with Results
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const { execSync } = require('child_process');
            
            // Run SwiftLint and capture output
            let output;
            try {
              output = execSync('swiftlint lint --reporter emoji', { encoding: 'utf8' });
            } catch (error) {
              output = error.stdout;
            }
            
            // Create or update comment
            const body = `## šŸ” SwiftLint Results\n\n\`\`\`\n${output}\n\`\`\`\n\nāœ“ View detailed report in the Actions artifacts.`;
            
            // Find existing comment
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            
            const botComment = comments.find(comment => 
              comment.user.type === 'Bot' && 
              comment.body.includes('šŸ” SwiftLint Results')
            );
            
            if (botComment) {
              // Update existing comment
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: body
              });
            } else {
              // Create new comment
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: body
              });
            }

Method 3: Changed Files Only (Performance Optimization)

.github/workflows/swiftlint-diff.yml
name: SwiftLint (Changed Files)

on:
  pull_request:
    paths:
      - '**.swift'

jobs:
  swiftlint-diff:
    name: Lint Changed Files
    runs-on: macos-latest
    
    steps:
      - name: Checkout PR
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      
      - name: Install SwiftLint
        run: brew install swiftlint
      
      - name: Get Changed Swift Files
        id: changed-files
        run: |
          # Get list of changed Swift files
          CHANGED_FILES=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}...HEAD | grep ".swift$" || true)
          
          if [ -z "$CHANGED_FILES" ]; then
            echo "No Swift files changed"
            echo "files=" >> $GITHUB_OUTPUT
          else
            # Convert to space-separated list
            FILES_LIST=$(echo "$CHANGED_FILES" | tr '\n' ' ')
            echo "files=$FILES_LIST" >> $GITHUB_OUTPUT
            echo "Changed files: $FILES_LIST"
          fi
      
      - name: Lint Changed Files
        if: steps.changed-files.outputs.files != ''
        run: |
          FILES="${{ steps.changed-files.outputs.files }}"
          
          echo "Linting changed files..."
          for file in $FILES; do
            echo "Checking: $file"
            swiftlint lint --path "$file" --strict
          done
      
      - name: Success Message
        if: success()
        run: |
          echo "āœ… All changed files pass SwiftLint!"

Handling SwiftLint Violations

Disabling Rules Inline

Sometimes you need to disable specific rules for legitimate reasons:

Swift
// Disable rule for next line
// swiftlint:disable:next force_cast
let name = json["name"] as! String

// Disable rule for block
// swiftlint:disable force_try
let data = try! JSONEncoder().encode(model)
// swiftlint:enable force_try

// Disable multiple rules
// swiftlint:disable line_length force_unwrapping
func veryLongFunctionName(parameter1: String, parameter2: String, parameter3: String) {
    let value = dictionary["key"]!
}
// swiftlint:enable line_length force_unwrapping

// Disable for entire file
// swiftlint:disable all
// Legacy code - will refactor later

Use Inline Disabling Sparingly

Disabling rules should be the exception, not the rule. Always add a comment explaining why you're disabling a rule. Consider if there's a better way to structure the code instead.

Team Adoption Strategy

Rolling out SwiftLint to an existing project with a large codebase requires strategy:

Phase 1: Audit (Week 1)

  1. Run SwiftLint on entire codebase
  2. Generate report: swiftlint lint --reporter html > report.html
  3. Review violations with team
  4. Identify quick wins (auto-fixable issues)

Phase 2: Auto-Fix (Week 2)

Terminal
# Auto-fix what can be fixed
swiftlint --fix --format

# Review changes
git diff

# Commit fixes
git add .
git commit -m "Apply SwiftLint auto-fixes"

Phase 3: Gradual Enforcement (Weeks 3-4)

  • Start with warning-only mode
  • Enable in CI but don't block merges initially
  • Fix violations in new code first
  • Create tickets for legacy code cleanup

Phase 4: Full Enforcement (Week 5+)

  • Enable strict mode in CI
  • Block PRs with violations
  • Add pre-commit hooks
  • Make it part of Definition of Done

Monitoring and Reporting

Generate Reports

Terminal
# HTML Report (visual)
swiftlint lint --reporter html > swiftlint-report.html

# JSON Report (for parsing)
swiftlint lint --reporter json > swiftlint-report.json

# JUnit Report (for CI systems)
swiftlint lint --reporter junit > swiftlint-junit.xml

# Markdown Report (for documentation)
swiftlint lint --reporter markdown > SWIFTLINT.md

Track Metrics Over Time

Create a script to track improvement:

scripts/track-lint-metrics.sh
#!/bin/bash

# Track SwiftLint metrics over time

DATE=$(date +%Y-%m-%d)
OUTPUT_DIR="metrics"
mkdir -p $OUTPUT_DIR

# Run SwiftLint and count violations
VIOLATIONS=$(swiftlint lint --quiet 2>&1 | wc -l)
WARNINGS=$(swiftlint lint --quiet 2>&1 | grep "warning:" | wc -l)
ERRORS=$(swiftlint lint --quiet 2>&1 | grep "error:" | wc -l)

# Append to CSV
echo "$DATE,$VIOLATIONS,$WARNINGS,$ERRORS" >> $OUTPUT_DIR/lint-history.csv

# Print summary
echo "šŸ“Š SwiftLint Metrics for $DATE"
echo "   Total Violations: $VIOLATIONS"
echo "   Warnings: $WARNINGS"
echo "   Errors: $ERRORS"

# Show trend
if [ -f "$OUTPUT_DIR/lint-history.csv" ]; then
    echo ""
    echo "šŸ“ˆ Last 5 runs:"
    tail -5 $OUTPUT_DIR/lint-history.csv | column -t -s,
fi

Best Practices & Pro Tips

SwiftLint Best Practices

  • Start with default rules and customize gradually
  • Run SwiftLint in both Xcode builds and CI/CD
  • Use --fix to automatically fix violations
  • Document why you disable specific rules
  • Review and update .swiftlint.yml regularly
  • Make SwiftLint part of onboarding for new developers
  • Use custom rules for project-specific conventions
  • Generate reports to track code quality trends
  • Don't disable rules globally if only needed in one place
  • Keep SwiftLint version consistent across team

Performance Tips

  • Use --cache-path to speed up repeated runs
  • Exclude generated files and dependencies
  • In CI, only lint changed files on PRs
  • Use SwiftLint 0.50+ for improved performance

Troubleshooting Common Issues

Issue: SwiftLint Not Found in Xcode Build

Solution
# Add PATH to run script
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"

if which swiftlint >/dev/null; then
    swiftlint
else
    echo "warning: SwiftLint not found"
fi

Issue: Too Many Violations

Gradually introduce rules:

.swiftlint.yml (Lenient Start)
# Start lenient, gradually enable rules
disabled_rules:
  - line_length
  - function_body_length
  - type_body_length
  - file_length
  - cyclomatic_complexity

# Only enforce critical rules initially
only_rules:
  - force_cast
  - force_try
  - force_unwrapping

Issue: Slow CI Builds

Use caching and parallel execution:

GitHub Actions
- name: SwiftLint with Cache
  run: |
    swiftlint lint --cache-path .swiftlint-cache --parallel

Conclusion

SwiftLint is an essential tool for maintaining code quality in Swift projects. By integrating it into your Xcode builds, Git workflow, and CI/CD pipeline, you ensure consistent, high-quality code across your entire team.

Key Takeaways:

  • āœ… Install SwiftLint via Homebrew or package managers
  • āœ… Configure with .swiftlint.yml to match your team's style
  • āœ… Integrate with Xcode builds for immediate feedback
  • āœ… Add pre-commit hooks to prevent bad commits
  • āœ… Automate with GitHub Actions for PR validation
  • āœ… Roll out gradually to avoid overwhelming the team
  • āœ… Use auto-fix to resolve violations quickly
  • āœ… Monitor metrics to track code quality improvements

Start small, iterate often, and watch your code quality improve over time. SwiftLint catches the small issues so your team can focus on solving the big problems.