HEIC is Apple's default camera format since iOS 11 — smaller than JPEG at the same quality, but not universally compatible. Swift has first-class HEIC support via ImageIO and CoreImage. Both frameworks are available on macOS and iOS. For server pipelines where you're not running on Apple hardware, the ChangeThisFile API handles HEIC decoding without requiring macOS.
Method 1: CoreImage (CIImage + CIContext, cleanest API)
CoreImage is the high-level path: load with CIImage, write with CIContext. Clean, concise, handles ICC profiles automatically.
import CoreImage
import Foundation
func heicToJpg(inputURL: URL, outputURL: URL, compressionQuality: CGFloat = 0.88) throws {
guard let ciImage = CIImage(contentsOf: inputURL) else {
throw ConversionError.unreadableInput(inputURL.path)
}
let context = CIContext(options: [.useSoftwareRenderer: false])
let colorSpace = ciImage.colorSpace ?? CGColorSpaceCreateDeviceRGB()
try context.writeJPEGRepresentation(
of: ciImage,
to: outputURL,
colorSpace: colorSpace,
options: [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: compressionQuality]
)
}
enum ConversionError: Error {
case unreadableInput(String)
}
// Usage
let input = URL(fileURLWithPath: "/tmp/photo.heic")
let output = URL(fileURLWithPath: "/tmp/photo.jpg")
try heicToJpg(inputURL: input, outputURL: output, compressionQuality: 0.88)
print("Saved: \(output.path)")
CIContext with useSoftwareRenderer: false uses the GPU when available — much faster for batch processing. The context is expensive to create; reuse it across multiple conversions rather than allocating a new one per file.
Compression quality guide:
- 0.60–0.70 — Visible artifacts on gradients. Use for thumbnails only.
- 0.80–0.88 — Good default. Visually indistinguishable from HEIC at this range.
- 0.90+ — Near-lossless. Larger than HEIC at equivalent quality.
Method 2: ImageIO (CGImageDestination, maximum control)
ImageIO gives you access to the raw CGImage pipeline — useful when you need to inspect EXIF data, copy metadata, or control color spaces explicitly.
import ImageIO
import UniformTypeIdentifiers
import Foundation
func heicToJpgImageIO(
inputURL: URL,
outputURL: URL,
compressionQuality: Double = 0.88,
copyExif: Bool = true
) throws {
guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
else {
throw ConversionError.unreadableInput(inputURL.path)
}
guard let destination = CGImageDestinationCreateWithURL(
outputURL as CFURL,
UTType.jpeg.identifier as CFString,
1, nil
) else {
throw ConversionError.cannotCreateDestination
}
var properties: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: compressionQuality
]
// Optionally copy EXIF/GPS metadata
if copyExif,
let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] {
properties.merge(metadata) { _, new in new }
}
CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw ConversionError.finalizeFailed
}
}
enum ConversionError: Error {
case unreadableInput(String)
case cannotCreateDestination
case finalizeFailed
}
// Usage
try heicToJpgImageIO(
inputURL: URL(fileURLWithPath: "/tmp/photo.heic"),
outputURL: URL(fileURLWithPath: "/tmp/photo.jpg"),
compressionQuality: 0.88,
copyExif: true
)
This approach copies EXIF metadata (camera model, exposure, date) from the HEIC into the JPEG. Set copyExif: false to strip metadata — useful for web uploads where you don't want to expose GPS coordinates.
Method 3: ChangeThisFile API via URLSession (no macOS required)
For Linux CI runners, cross-platform pipelines, or cases where you don't have macOS available, POST the file to /v1/convert. Free tier: 1,000 conversions/month.
# curl reference
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@photo.heic" \
-F "target=jpg" \
--output photo.jpg
import Foundation
func heicToJpgAPI(
inputURL: URL,
outputURL: URL,
apiKey: String,
completion: @escaping (Result) -> Void
) {
let endpoint = URL(string: "https://changethisfile.com/v1/convert")!
let boundary = UUID().uuidString
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
let fileData = try! Data(contentsOf: inputURL)
// File field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(inputURL.lastPathComponent)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/heic\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n".data(using: .utf8)!)
// Target field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"target\"\r\n\r\n".data(using: .utf8)!)
body.append("jpg\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error { return completion(.failure(error)) }
guard let data = data, !data.isEmpty else {
return completion(.failure(URLError(.zeroByteResource)))
}
do {
try data.write(to: outputURL)
completion(.success(outputURL))
} catch {
completion(.failure(error))
}
}.resume()
}
// Usage (async/await wrapper)
func convertHEIC() async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
heicToJpgAPI(
inputURL: URL(fileURLWithPath: "/tmp/photo.heic"),
outputURL: URL(fileURLWithPath: "/tmp/photo.jpg"),
apiKey: "ctf_sk_your_key_here"
) { result in continuation.resume(with: result) }
}
}
Source format is auto-detected. The API accepts both .heic and .heif extensions.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| CoreImage | Batch conversion on macOS/iOS, GPU acceleration | macOS/iOS only; CIContext reuse required for performance |
| ImageIO | EXIF metadata control, precise color space handling | More verbose; macOS/iOS only |
| ChangeThisFile API (URLSession) | Linux CI, cross-platform, no macOS runtime | Network latency, 25MB free-tier upload limit |
Production tips
- Reuse CIContext. Creating a CIContext allocates a GPU context — an expensive operation. Create once at app launch or as a static property and reuse across all conversions.
- Strip EXIF for web uploads. HEIC photos from iPhone contain GPS coordinates in EXIF. Use
copyExif: falsein the ImageIO approach, or CGImageDestinationCopyImageSource with property overrides to strip location data before serving publicly. - Handle HEIC-unsupported hardware gracefully. HEIC decode requires hardware support on older devices. Wrap in do/catch and fall back to a server-side conversion if CIImage(contentsOf:) returns nil.
- Batch with DispatchQueue.concurrentPerform. For converting a photo library, use DispatchQueue.concurrentPerform(iterations: count) with a shared (thread-safe) CIContext. The concurrent queue fills CPU cores without over-spinning threads.
- Check output file size. HEIC files with embedded depth maps or multi-frame captures (Live Photos) convert only the primary image. The output JPG will be smaller than expected — this is correct behavior, not a bug.
For macOS/iOS apps and scripts, CoreImage is the fast path. When EXIF control matters, use ImageIO. For CI pipelines on Linux or non-Apple hardware, the API. Free tier covers 1,000 conversions/month.